├── .gitignore ├── .prettierrc.json ├── .gitattributes ├── tests ├── test.tar.gz ├── setup.ts ├── save.test.ts ├── cache.test.ts └── restore.test.ts ├── tsconfig.json ├── jest.config.ts ├── .github ├── dependabot.yaml └── workflows │ └── ci.yaml ├── save ├── README.md └── action.yaml ├── eslint.config.mjs ├── src ├── constants.ts ├── utils.ts ├── save.ts ├── restore.ts ├── client.ts └── cache.ts ├── restore ├── README.md └── action.yaml ├── LICENSE ├── action.yaml ├── CHANGELOG.md ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /lib 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100 3 | } 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | /dist/** binary linguist-generated=true 3 | -------------------------------------------------------------------------------- /tests/test.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itchyny/s3-cache-action/HEAD/tests/test.tar.gz -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*"], 3 | "compilerOptions": { 4 | "target": "ES2022", 5 | "module": "CommonJS", 6 | "rootDir": "src", 7 | "outDir": "lib", 8 | "strict": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "jest"; 2 | 3 | const config: Config = { 4 | preset: "ts-jest", 5 | testEnvironment: "node", 6 | testMatch: ["**/*.test.ts"], 7 | transform: { "\\.ts$": "ts-jest" }, 8 | setupFilesAfterEnv: ["./tests/setup.ts"], 9 | }; 10 | 11 | export default config; 12 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: cron 7 | cronjob: 0 0 1 6,12 * 8 | groups: 9 | github-actions: 10 | patterns: ["*"] 11 | - package-ecosystem: npm 12 | directory: / 13 | versioning-strategy: increase 14 | schedule: 15 | interval: cron 16 | cronjob: 0 0 1 6,12 * 17 | groups: 18 | npm-dependencies: 19 | dependency-type: production 20 | npm-dev-dependencies: 21 | dependency-type: development 22 | -------------------------------------------------------------------------------- /save/README.md: -------------------------------------------------------------------------------- 1 | # s3-cache-action/save 2 | This action saves the cache to Amazon S3. 3 | 4 | ## Usage 5 | ```yaml 6 | - uses: itchyny/s3-cache-action/save@v1 7 | with: 8 | path: ~/.npm 9 | key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 10 | bucket-name: ${{ vars.S3_CACHE_BUCKET_NAME }} 11 | aws-region: ${{ vars.S3_CACHE_AWS_REGION }} 12 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 13 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 14 | ``` 15 | 16 | See [s3-cache-action](../) for more details. 17 | Refer to [action.yaml](action.yaml) for the documentation of the inputs. 18 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import prettierRecommended from "eslint-plugin-prettier/recommended"; 3 | import simpleImportSort from "eslint-plugin-simple-import-sort"; 4 | import unusedImports from "eslint-plugin-unused-imports"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | export default tseslint.config( 8 | eslint.configs.recommended, 9 | ...tseslint.configs.recommended, 10 | prettierRecommended, 11 | { 12 | plugins: { 13 | "unused-imports": unusedImports, 14 | "simple-import-sort": simpleImportSort, 15 | }, 16 | rules: { 17 | "unused-imports/no-unused-imports": ["error"], 18 | "simple-import-sort/imports": "error", 19 | "simple-import-sort/exports": "error", 20 | }, 21 | }, 22 | ); 23 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export enum Inputs { 2 | Path = "path", 3 | Key = "key", 4 | RestoreKeys = "restore-keys", 5 | LookupOnly = "lookup-only", 6 | FailOnCacheMiss = "fail-on-cache-miss", 7 | BucketName = "bucket-name", 8 | AWSRegion = "aws-region", 9 | AWSAccessKeyId = "aws-access-key-id", 10 | AWSSecretAccessKey = "aws-secret-access-key", 11 | AWSSessionToken = "aws-session-token", 12 | } 13 | 14 | export enum Env { 15 | AWSRegion = "AWS_REGION", 16 | AWSAccessKeyId = "AWS_ACCESS_KEY_ID", 17 | AWSSecretAccessKey = "AWS_SECRET_ACCESS_KEY", 18 | AWSSessionToken = "AWS_SESSION_TOKEN", 19 | } 20 | 21 | export enum Outputs { 22 | CacheHit = "cache-hit", 23 | } 24 | 25 | export enum State { 26 | CachePath = "CACHE_PATH", 27 | CacheKey = "CACHE_KEY", 28 | CacheHit = "CACHE_HIT", 29 | } 30 | -------------------------------------------------------------------------------- /restore/README.md: -------------------------------------------------------------------------------- 1 | # s3-cache-action/restore 2 | This action restores the cache from Amazon S3. 3 | 4 | ## Usage 5 | ```yaml 6 | - uses: itchyny/s3-cache-action/restore@v1 7 | id: restore-cache 8 | with: 9 | path: ~/.npm 10 | key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 11 | restore-keys: | 12 | npm-${{ runner.os }}- 13 | bucket-name: ${{ vars.S3_CACHE_BUCKET_NAME }} 14 | aws-region: ${{ vars.S3_CACHE_AWS_REGION }} 15 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 16 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 17 | - name: Install dependencies 18 | if: steps.restore-cache.outputs.cache-hit != 'true' 19 | run: npm ci --ignore-scripts 20 | ``` 21 | 22 | See [s3-cache-action](../) for more details. 23 | Refer to [action.yaml](action.yaml) for the documentation of the inputs and outputs. 24 | -------------------------------------------------------------------------------- /save/action.yaml: -------------------------------------------------------------------------------- 1 | name: Save cache files to Amazon S3 2 | description: GitHub Action to save cache files to Amazon S3 3 | author: itchyny 4 | inputs: 5 | path: 6 | description: A list of file paths or glob patterns to save 7 | required: true 8 | key: 9 | description: A key to use for saving the files 10 | required: true 11 | bucket-name: 12 | description: Bucket name to save cache files 13 | required: true 14 | aws-region: 15 | description: AWS region of the bucket 16 | required: false 17 | aws-access-key-id: 18 | description: AWS access key ID to access the bucket 19 | required: false 20 | aws-secret-access-key: 21 | description: AWS secret access key to access the bucket 22 | required: false 23 | aws-session-token: 24 | description: AWS session token to access the bucket 25 | required: false 26 | runs: 27 | using: node20 28 | main: ../dist/save/index.js 29 | branding: 30 | icon: archive 31 | color: gray-dark 32 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import * as s3 from "@aws-sdk/client-s3"; 3 | 4 | import { Env, Inputs } from "./constants"; 5 | 6 | export function splitInput(str: string): string[] { 7 | return str 8 | .split("\n") 9 | .map((s) => s.trim()) 10 | .filter((s) => s !== "" && !s.startsWith("#")); 11 | } 12 | 13 | export function newS3Client(): s3.S3Client { 14 | const region = getAWSInput("AWSRegion"); 15 | const accessKeyId = getAWSInput("AWSAccessKeyId"); 16 | const secretAccessKey = getAWSInput("AWSSecretAccessKey"); 17 | const sessionToken = getAWSInput("AWSSessionToken"); 18 | return new s3.S3Client({ 19 | region, 20 | credentials: { accessKeyId, secretAccessKey, sessionToken }, 21 | }); 22 | } 23 | 24 | function getAWSInput(key: keyof typeof Inputs & keyof typeof Env): string { 25 | const value = 26 | core.getState(Env[key]) || core.getInput(Inputs[key]) || process.env[Env[key]] || ""; 27 | core.saveState(Env[key], value); 28 | return value; 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 itchyny 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/save.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | 3 | import { saveCache } from "./cache"; 4 | import { Inputs, State } from "./constants"; 5 | import { newS3Client, splitInput } from "./utils"; 6 | 7 | export async function save() { 8 | // Get the inputs. 9 | const path = splitInput( 10 | core.getState(State.CachePath) || core.getInput(Inputs.Path, { required: true }), 11 | ); 12 | const key = core.getState(State.CacheKey) || core.getInput(Inputs.Key, { required: true }); 13 | const bucketName = core.getInput(Inputs.BucketName, { required: true }); 14 | core.debug(`${Inputs.Path}: [${path.join(", ")}]`); 15 | core.debug(`${Inputs.Key}: ${key}`); 16 | core.debug(`${Inputs.BucketName}: ${bucketName}`); 17 | 18 | // If the cache has already been restored, don't save it again. 19 | if (core.getState(State.CacheHit) === "true") { 20 | core.info(`Cache restored from S3 with key ${key}, not saving cache.`); 21 | return; 22 | } 23 | 24 | // Save the cache to S3. 25 | await saveCache(path, key, bucketName, newS3Client()); 26 | } 27 | 28 | if (require.main === module) { 29 | (async () => { 30 | try { 31 | await save(); 32 | } catch (error: unknown) { 33 | if (error instanceof Error) { 34 | core.setFailed(error); 35 | } 36 | } 37 | })(); 38 | } 39 | -------------------------------------------------------------------------------- /restore/action.yaml: -------------------------------------------------------------------------------- 1 | name: Restore cache files from Amazon S3 2 | description: GitHub Action to restore cache files from Amazon S3 3 | author: itchyny 4 | inputs: 5 | path: 6 | description: A list of file paths or glob patterns to restore 7 | required: true 8 | key: 9 | description: A key to use for restoring the files 10 | required: true 11 | restore-keys: 12 | description: An ordered list of keys to use for restoring the files 13 | required: false 14 | lookup-only: 15 | description: A boolean value indicating whether to lookup without downloading the cache 16 | required: false 17 | fail-on-cache-miss: 18 | description: A boolean value indicating whether to fail if the cache is not found 19 | required: false 20 | bucket-name: 21 | description: Bucket name to restore cache files 22 | required: true 23 | aws-region: 24 | description: AWS region of the bucket 25 | required: false 26 | aws-access-key-id: 27 | description: AWS access key ID to access the bucket 28 | required: false 29 | aws-secret-access-key: 30 | description: AWS secret access key to access the bucket 31 | required: false 32 | aws-session-token: 33 | description: AWS session token to access the bucket 34 | required: false 35 | outputs: 36 | cache-hit: 37 | description: 38 | A string "true" or "false" indicating whether the cache hit with the key. 39 | If the cache is restored with one of the restore-keys, the value is "false". 40 | If the cache is not restored, the value is "". 41 | runs: 42 | using: node20 43 | main: ../dist/restore/index.js 44 | branding: 45 | icon: archive 46 | color: gray-dark 47 | -------------------------------------------------------------------------------- /action.yaml: -------------------------------------------------------------------------------- 1 | name: Cache files to Amazon S3 2 | description: GitHub Action to save cache files and restore them from Amazon S3 3 | author: itchyny 4 | inputs: 5 | path: 6 | description: A list of file paths or glob patterns to save and restore 7 | required: true 8 | key: 9 | description: A key to use for restoring and saving the files 10 | required: true 11 | restore-keys: 12 | description: An ordered list of keys to use for restoring the files 13 | required: false 14 | lookup-only: 15 | description: A boolean value indicating whether to lookup without downloading the cache 16 | required: false 17 | fail-on-cache-miss: 18 | description: A boolean value indicating whether to fail if the cache is not found 19 | required: false 20 | bucket-name: 21 | description: Bucket name to save and restore cache files 22 | required: true 23 | aws-region: 24 | description: AWS region of the bucket 25 | required: false 26 | aws-access-key-id: 27 | description: AWS access key ID to access the bucket 28 | required: false 29 | aws-secret-access-key: 30 | description: AWS secret access key to access the bucket 31 | required: false 32 | aws-session-token: 33 | description: AWS session token to access the bucket 34 | required: false 35 | outputs: 36 | cache-hit: 37 | description: 38 | A string "true" or "false" indicating whether the cache hit with the key. 39 | If the cache is restored with one of the restore-keys, the value is "false". 40 | If the cache is not restored, the value is "". 41 | runs: 42 | using: node20 43 | main: dist/restore/index.js 44 | post: dist/save/index.js 45 | post-if: success() 46 | branding: 47 | icon: archive 48 | color: gray-dark 49 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ## [v1.1.5](https://github.com/itchyny/s3-cache-action/compare/v1.1.4..v1.1.5) (2025-12-03) 3 | * update `@aws-sdk/client-s3` and `@aws-sdk/lib-storage` from 3.821.0 to 3.940.0 4 | * update `tar` from 7.4.3 to 7.5.2 5 | * update `tmp` from 0.2.3 to 0.2.5 6 | 7 | ## [v1.1.4](https://github.com/itchyny/s3-cache-action/compare/v1.1.3..v1.1.4) (2025-06-01) 8 | * update `@aws-sdk/client-s3` and `@aws-sdk/lib-storage` from 3.701.0 to 3.821.0 9 | * update `tar` from 6.2.1 to 7.4.3 10 | 11 | ## [v1.1.3](https://github.com/itchyny/s3-cache-action/compare/v1.1.2..v1.1.3) (2024-12-01) 12 | * update `@actions/core` from 1.10.1 to 1.11.1 13 | * update `@aws-sdk/client-s3` and `@aws-sdk/lib-storage` from 3.658.1 to 3.701.0 14 | 15 | ## [v1.1.2](https://github.com/itchyny/s3-cache-action/compare/v1.1.1..v1.1.2) (2024-10-01) 16 | * update `@actions/glob` from 0.4.0 to 0.5.0 17 | * update `@aws-sdk/client-s3` and `@aws-sdk/lib-storage` from 3.600.0 to 3.658.1 18 | 19 | ## [v1.1.1](https://github.com/itchyny/s3-cache-action/compare/v1.1.0..v1.1.1) (2024-06-19) 20 | * improve logic for checking cache hit state to skip saving cache 21 | * update `@aws-sdk/client-s3` and `@aws-sdk/lib-storage` from 3.540.0 to 3.600.0 22 | 23 | ## [v1.1.0](https://github.com/itchyny/s3-cache-action/compare/v1.0.1..v1.1.0) (2024-04-08) 24 | * implement `lookup-only` option to lookup the cache without downloading it 25 | * implement `fail-on-cache-miss` option to fail the action if the cache is not found 26 | 27 | ## [v1.0.1](https://github.com/itchyny/s3-cache-action/compare/v1.0.0..v1.0.1) (2024-04-01) 28 | * update `@aws-sdk/client-s3` and `@aws-sdk/lib-storage` from 3.529.1 to 3.540.0 29 | * update `tar` from 6.2.0 to 6.2.1 30 | 31 | ## [v1.0.0](https://github.com/itchyny/s3-cache-action/compare/80e5042..v1.0.0) (2024-03-22) 32 | * initial implementation of `s3-cache-action` and the npm package 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@itchyny/s3-cache-action", 3 | "version": "1.1.5", 4 | "description": "Cache files to Amazon S3", 5 | "author": "itchyny ", 6 | "license": "MIT", 7 | "homepage": "https://github.com/itchyny/s3-cache-action", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/itchyny/s3-cache-action.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/itchyny/s3-cache-action/issues" 14 | }, 15 | "main": "lib/cache.js", 16 | "types": "lib/cache.d.ts", 17 | "files": [ 18 | "lib" 19 | ], 20 | "publishConfig": { 21 | "access": "public" 22 | }, 23 | "keywords": [ 24 | "s3", 25 | "cache", 26 | "actions" 27 | ], 28 | "scripts": { 29 | "build": "ncc build -m -o dist/restore src/restore.ts && ncc build -m -o dist/save src/save.ts", 30 | "test": "jest --runInBand", 31 | "lint": "eslint *.ts *.mjs src/*.ts tests/*.ts", 32 | "lint:fix": "eslint --fix *.ts *.mjs src/*.ts tests/*.ts", 33 | "prepublishOnly": "tsc --declaration" 34 | }, 35 | "dependencies": { 36 | "@actions/core": "^1.11.1", 37 | "@actions/glob": "^0.5.0", 38 | "@aws-sdk/client-s3": "^3.940.0", 39 | "@aws-sdk/lib-storage": "^3.940.0", 40 | "tar": "^7.5.2", 41 | "tmp": "^0.2.5" 42 | }, 43 | "devDependencies": { 44 | "@types/jest": "^30.0.0", 45 | "@types/node": "^24.10.1", 46 | "@types/tmp": "^0.2.6", 47 | "@vercel/ncc": "^0.38.4", 48 | "aws-sdk-client-mock": "^4.1.0", 49 | "aws-sdk-client-mock-jest": "^4.1.0", 50 | "eslint": "^9.39.1", 51 | "eslint-config-prettier": "^10.1.8", 52 | "eslint-plugin-prettier": "^5.5.4", 53 | "eslint-plugin-simple-import-sort": "^12.1.1", 54 | "eslint-plugin-unused-imports": "^4.3.0", 55 | "jest": "^30.2.0", 56 | "prettier": "^3.7.3", 57 | "ts-jest": "^29.4.6", 58 | "ts-node": "^10.9.2", 59 | "typescript": "^5.9.3", 60 | "typescript-eslint": "^8.48.1" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/restore.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | 3 | import { lookupCache, restoreCache } from "./cache"; 4 | import { Inputs, Outputs, State } from "./constants"; 5 | import { newS3Client, splitInput } from "./utils"; 6 | 7 | export async function restore() { 8 | // Get the inputs. 9 | const path = splitInput(core.getInput(Inputs.Path, { required: true })); 10 | const key = core.getInput(Inputs.Key, { required: true }); 11 | const restoreKeys = splitInput(core.getInput(Inputs.RestoreKeys)); 12 | const lookupOnly = core.getInput(Inputs.LookupOnly) === "true"; 13 | const failOnCacheMiss = core.getInput(Inputs.FailOnCacheMiss) === "true"; 14 | const bucketName = core.getInput(Inputs.BucketName, { required: true }); 15 | core.debug(`${Inputs.Path}: [${path.join(", ")}]`); 16 | core.debug(`${Inputs.Key}: ${key}`); 17 | core.debug(`${Inputs.RestoreKeys}: [${restoreKeys.join(", ")}]`); 18 | core.debug(`${Inputs.LookupOnly}: ${lookupOnly}`); 19 | core.debug(`${Inputs.FailOnCacheMiss}: ${failOnCacheMiss}`); 20 | core.debug(`${Inputs.BucketName}: ${bucketName}`); 21 | 22 | // Save the inputs to the state for the post job, to avoid re-evaluations. 23 | core.saveState(State.CachePath, path.join("\n")); 24 | core.saveState(State.CacheKey, key); 25 | 26 | // Restore or lookup the cache from S3. 27 | const matchedKey = lookupOnly 28 | ? await lookupCache(path, key, restoreKeys, bucketName, newS3Client()) 29 | : await restoreCache(path, key, restoreKeys, bucketName, newS3Client()); 30 | if (matchedKey) { 31 | core.saveState(State.CacheHit, matchedKey === key); 32 | core.setOutput(Outputs.CacheHit, matchedKey === key); 33 | } else if (failOnCacheMiss) { 34 | throw new Error( 35 | `Cache not found in S3 with key: ${key}, restore keys: [${restoreKeys.join(", ")}]`, 36 | ); 37 | } 38 | } 39 | 40 | if (require.main === module) { 41 | (async () => { 42 | try { 43 | await restore(); 44 | } catch (error: unknown) { 45 | if (error instanceof Error) { 46 | core.setFailed(error); 47 | } 48 | } 49 | })(); 50 | } 51 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import * as s3 from "@aws-sdk/client-s3"; 3 | import { Upload } from "@aws-sdk/lib-storage"; 4 | import { once } from "events"; 5 | import * as fs from "fs"; 6 | import { Readable } from "stream"; 7 | import { pipeline } from "stream/promises"; 8 | 9 | export class Client { 10 | constructor( 11 | private readonly bucketName: string, 12 | private readonly client: s3.S3Client, 13 | ) {} 14 | 15 | private static joinKey(key: string, file: string): string { 16 | return `${key}/${file}`; 17 | } 18 | 19 | private static matchFile(objectKey: string, file: string): boolean { 20 | return objectKey.endsWith(`/${file}`); 21 | } 22 | 23 | private static getKey(objectKey: string): string { 24 | const index = objectKey.lastIndexOf("/"); 25 | if (index === -1) { 26 | throw new Error(`Invalid object key: ${objectKey}`); 27 | } 28 | return objectKey.substring(0, index); 29 | } 30 | 31 | async getObject(key: string, file: string, stream: fs.WriteStream): Promise { 32 | core.debug(`Getting object from S3 with key ${key}, file ${file}.`); 33 | const command = new s3.GetObjectCommand({ 34 | Bucket: this.bucketName, 35 | Key: Client.joinKey(key, file), 36 | }); 37 | try { 38 | const response = await this.client.send(command); 39 | await pipeline(response.Body! as Readable, stream); 40 | return true; 41 | } catch (error: unknown) { 42 | if (error instanceof s3.NoSuchKey) { 43 | return false; 44 | } 45 | throw error; 46 | } finally { 47 | if (!stream.closed) { 48 | stream.destroy(); 49 | await once(stream, "close"); 50 | } 51 | } 52 | } 53 | 54 | async headObject(key: string, file: string): Promise { 55 | core.debug(`Heading object from S3 with key ${key}, file ${file}.`); 56 | const command = new s3.HeadObjectCommand({ 57 | Bucket: this.bucketName, 58 | Key: Client.joinKey(key, file), 59 | }); 60 | try { 61 | await this.client.send(command); 62 | return true; 63 | } catch (error: unknown) { 64 | if (error instanceof s3.NotFound) { 65 | return false; 66 | } 67 | throw error; 68 | } 69 | } 70 | 71 | async listObjects(prefix: string, file: string): Promise { 72 | core.debug(`Listing objects from S3 with prefix ${prefix}.`); 73 | const command = new s3.ListObjectsV2Command({ 74 | Bucket: this.bucketName, 75 | Prefix: prefix, 76 | }); 77 | const response = await this.client.send(command); 78 | if (response.IsTruncated) { 79 | core.info( 80 | `Too many objects in S3 with prefix ${prefix}, ` + 81 | `only ${response.KeyCount} objects will be checked.`, 82 | ); 83 | } 84 | return ( 85 | response.Contents?.filter((object) => Client.matchFile(object.Key!, file)) 86 | .sort((x, y) => (x.LastModified!.getTime() < y.LastModified!.getTime() ? 1 : -1)) 87 | .map((object) => Client.getKey(object.Key!)) ?? [] 88 | ); 89 | } 90 | 91 | async putObject(key: string, file: string, stream: fs.ReadStream): Promise { 92 | core.debug(`Putting object to S3 with key ${key}, file ${file}.`); 93 | const upload = new Upload({ 94 | client: this.client, 95 | params: { 96 | Bucket: this.bucketName, 97 | Key: Client.joinKey(key, file), 98 | Body: stream, 99 | }, 100 | }); 101 | upload.on("httpUploadProgress", ({ loaded, total }) => { 102 | core.debug(`Uploaded ${loaded} of ${total} bytes.`); 103 | }); 104 | await upload.done(); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | import * as s3 from "@aws-sdk/client-s3"; 2 | import { SdkStream } from "@smithy/types"; 3 | import { sdkStreamMixin } from "@smithy/util-stream"; 4 | import { mockClient } from "aws-sdk-client-mock"; 5 | import * as fs from "fs"; 6 | import { Readable } from "stream"; 7 | import * as tmp from "tmp"; 8 | 9 | export const s3Mock = mockClient(s3.S3Client); 10 | const cleanupFiles: string[] = []; 11 | 12 | beforeEach(() => { 13 | clearInputs(); 14 | delete process.env.STATE_CACHE_PATH; 15 | delete process.env.STATE_CACHE_KEY; 16 | delete process.env.STATE_CACHE_HIT; 17 | process.env.GITHUB_STATE = createEmptyFile(); 18 | process.env.GITHUB_OUTPUT = createEmptyFile(); 19 | s3Mock.callsFake((input) => { 20 | throw new Error(`Unexpected S3 API call: ${JSON.stringify(input)}`); 21 | }); 22 | cleanupFiles.length = 0; 23 | jest.spyOn(process.stdout, "write").mockImplementation(() => true); 24 | }); 25 | 26 | afterEach(() => { 27 | fs.unlinkSync(process.env.GITHUB_STATE!); 28 | fs.unlinkSync(process.env.GITHUB_OUTPUT!); 29 | delete process.env.GITHUB_STATE; 30 | delete process.env.GITHUB_OUTPUT; 31 | s3Mock.reset(); 32 | for (const file of cleanupFiles) { 33 | fs.unlinkSync(file); 34 | } 35 | }); 36 | 37 | function createEmptyFile(): string { 38 | const file = tmp.tmpNameSync(); 39 | fs.writeFileSync(file, ""); 40 | return file; 41 | } 42 | 43 | function clearInputs(): void { 44 | delete process.env["INPUT_PATH"]; 45 | delete process.env["INPUT_KEY"]; 46 | delete process.env["INPUT_RESTORE-KEYS"]; 47 | delete process.env["INPUT_LOOKUP-ONLY"]; 48 | delete process.env["INPUT_FAIL-ON-CACHE-MISS"]; 49 | delete process.env["INPUT_BUCKET-NAME"]; 50 | } 51 | 52 | export function setupInputs({ 53 | path = "", 54 | key = "", 55 | restoreKeys = [], 56 | lookupOnly = false, 57 | failOnCacheMiss = false, 58 | bucketName = "test-bucket-name", 59 | awsRegion = "ap-northeast-1", 60 | awsAccessKeyId = "", 61 | awsSecretAccessKey = "", 62 | awsSessionToken = "", 63 | }: { 64 | path?: string; 65 | key?: string; 66 | restoreKeys?: string[]; 67 | lookupOnly?: boolean; 68 | failOnCacheMiss?: boolean; 69 | bucketName?: string; 70 | awsRegion?: string; 71 | awsAccessKeyId?: string; 72 | awsSecretAccessKey?: string; 73 | awsSessionToken?: string; 74 | }): void { 75 | process.env["INPUT_PATH"] = path; 76 | process.env["INPUT_KEY"] = key; 77 | process.env["INPUT_RESTORE-KEYS"] = restoreKeys.join("\n"); 78 | process.env["INPUT_LOOKUP-ONLY"] = lookupOnly.toString(); 79 | process.env["INPUT_FAIL-ON-CACHE-MISS"] = failOnCacheMiss.toString(); 80 | process.env["INPUT_BUCKET-NAME"] = bucketName; 81 | process.env["INPUT_AWS-REGION"] = awsRegion; 82 | process.env["INPUT_AWS-ACCESS-KEY-ID"] = awsAccessKeyId; 83 | process.env["INPUT_AWS-SECRET-ACCESS-KEY"] = awsSecretAccessKey; 84 | process.env["INPUT_AWS-SESSION-TOKEN"] = awsSessionToken; 85 | } 86 | 87 | export function setState(key: string, value: string): void { 88 | process.env[`STATE_${key}`] = value; 89 | } 90 | 91 | export function addCleanupFiles(...files: string[]): void { 92 | cleanupFiles.push(...files); 93 | } 94 | 95 | export function createReadStream(file: string): SdkStream { 96 | return sdkStreamMixin(fs.createReadStream(file)); 97 | } 98 | 99 | export function getState(key: string): string { 100 | return get("State", process.env.GITHUB_STATE!, key); 101 | } 102 | 103 | export function getOutput(key: string): string { 104 | return get("Output", process.env.GITHUB_OUTPUT!, key); 105 | } 106 | 107 | function get(target: "State" | "Output", file: string, key: string): string { 108 | const pattern = new RegExp( 109 | `^${key}< { 9 | it("should save the cache successfully", async () => { 10 | setupInputs({ path: "tests", key: "test-key" }); 11 | 12 | s3Mock 13 | .on(s3.HeadObjectCommand, { 14 | Key: "test-key/b61a6d542f9036550ba9c401c80f00ef.tar.gz", 15 | }) 16 | .rejects(new s3.NotFound({ $metadata: {}, message: "Not Found" })) 17 | .on(s3.PutObjectCommand, { 18 | Key: "test-key/b61a6d542f9036550ba9c401c80f00ef.tar.gz", 19 | }) 20 | .resolves({}); 21 | 22 | await save(); 23 | expect(s3Mock).toHaveReceivedCommandTimes(s3.HeadObjectCommand, 1); 24 | expect(s3Mock).toHaveReceivedCommandTimes(s3.PutObjectCommand, 1); 25 | }); 26 | 27 | it("should save the cache when inputs are stored in the state", async () => { 28 | setupInputs({}); 29 | setState("CACHE_PATH", "tests"); 30 | setState("CACHE_KEY", "test-key"); 31 | 32 | s3Mock 33 | .on(s3.HeadObjectCommand, { 34 | Key: "test-key/b61a6d542f9036550ba9c401c80f00ef.tar.gz", 35 | }) 36 | .rejects(new s3.NotFound({ $metadata: {}, message: "Not Found" })) 37 | .on(s3.PutObjectCommand, { 38 | Key: "test-key/b61a6d542f9036550ba9c401c80f00ef.tar.gz", 39 | }) 40 | .resolves({}); 41 | 42 | await save(); 43 | expect(s3Mock).toHaveReceivedCommandTimes(s3.HeadObjectCommand, 1); 44 | expect(s3Mock).toHaveReceivedCommandTimes(s3.PutObjectCommand, 1); 45 | }); 46 | 47 | it("should save the cache with glob path", async () => { 48 | setupInputs({ path: "*.json", key: "test-key" }); 49 | 50 | s3Mock 51 | .on(s3.HeadObjectCommand, { 52 | Key: "test-key/b31ec5f19793e2b7103acd7336754a1c.tar.gz", 53 | }) 54 | .rejects(new s3.NotFound({ $metadata: {}, message: "Not Found" })) 55 | .on(s3.PutObjectCommand, { 56 | Key: "test-key/b31ec5f19793e2b7103acd7336754a1c.tar.gz", 57 | }) 58 | .resolves({}); 59 | 60 | await save(); 61 | expect(s3Mock).toHaveReceivedCommandTimes(s3.HeadObjectCommand, 1); 62 | expect(s3Mock).toHaveReceivedCommandTimes(s3.PutObjectCommand, 1); 63 | }); 64 | 65 | it("should not save the cache if the cache has been restored", async () => { 66 | setupInputs({ path: "tests", key: "test-key" }); 67 | setState("CACHE_HIT", "true"); 68 | 69 | await save(); 70 | expect(s3Mock).not.toHaveReceivedAnyCommand(); 71 | }); 72 | 73 | it("should save the cache if the cache has been restored with a different key", async () => { 74 | setupInputs({ path: "tests", key: "test-key" }); 75 | setState("CACHE_HIT", "false"); 76 | 77 | s3Mock 78 | .on(s3.HeadObjectCommand, { 79 | Key: "test-key/b61a6d542f9036550ba9c401c80f00ef.tar.gz", 80 | }) 81 | .rejects(new s3.NotFound({ $metadata: {}, message: "Not Found" })) 82 | .on(s3.PutObjectCommand, { 83 | Key: "test-key/b61a6d542f9036550ba9c401c80f00ef.tar.gz", 84 | }) 85 | .resolves({}); 86 | 87 | await save(); 88 | expect(s3Mock).toHaveReceivedCommandTimes(s3.HeadObjectCommand, 1); 89 | expect(s3Mock).toHaveReceivedCommandTimes(s3.PutObjectCommand, 1); 90 | }); 91 | 92 | it("should not save the cache if the cache has been saved already", async () => { 93 | setupInputs({ path: "tests", key: "test-key" }); 94 | 95 | s3Mock 96 | .on(s3.HeadObjectCommand, { 97 | Key: "test-key/b61a6d542f9036550ba9c401c80f00ef.tar.gz", 98 | }) 99 | .resolves({}); 100 | 101 | await save(); 102 | expect(s3Mock).toHaveReceivedCommandTimes(s3.HeadObjectCommand, 1); 103 | }); 104 | 105 | it("should throw an error when path input is not supplied", async () => { 106 | setupInputs({ path: "", key: "test-key" }); 107 | 108 | await expect(save()).rejects.toThrow("Input required and not supplied: path"); 109 | }); 110 | 111 | it("should throw an error when key input is not supplied", async () => { 112 | setupInputs({ path: "tests", key: "" }); 113 | 114 | await expect(save()).rejects.toThrow("Input required and not supplied: key"); 115 | expect(s3Mock).not.toHaveReceivedAnyCommand(); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # s3-cache-action 2 | This action is a minimal implementation of a cache action that caches files to Amazon S3. 3 | This action works similarly to [actions/cache](https://github.com/actions/cache), but uses Amazon S3 as the backend. 4 | 5 | ## Usage 6 | The action can be used in the same way as `actions/cache`, but requires input parameters for S3 bucket name and AWS credentials. 7 | Firstly, learn the basic usage of `actions/cache` in [GitHub Docs: Using the cache action](https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#using-the-cache-action). 8 | The input parameters `path`, `key`, `restore-keys`, `lookup-only`, `fail-on-cache-miss`, and the output parameter `cache-hit` are compatible with `actions/cache`. 9 | For examples of caching configurations in each language, see [actions/cache: Implementation Examples](https://github.com/actions/cache#implementation-examples). 10 | 11 | ```yaml 12 | - uses: itchyny/s3-cache-action@v1 13 | with: 14 | path: ~/.npm 15 | key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 16 | restore-keys: | 17 | npm-${{ runner.os }}- 18 | bucket-name: ${{ vars.S3_CACHE_BUCKET_NAME }} 19 | aws-region: ${{ vars.S3_CACHE_AWS_REGION }} 20 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 21 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 22 | ``` 23 | 24 | Attach `s3:GetObject`, `s3:PutObject` on the bucket objects, and `s3:ListBucket` on the bucket to the IAM role of the AWS credentials. 25 | 26 | You can also use [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) to configure the AWS credentials. 27 | However, note that the credentials are stored in the environment variables, and can be accessed in subsequent steps. 28 | 29 | ```yaml 30 | - uses: aws-actions/configure-aws-credentials@v4 31 | with: 32 | aws-region: ${{ vars.S3_CACHE_AWS_REGION }} 33 | role-to-assume: ${{ vars.S3_CACHE_ASSUME_ROLE_ARN }} 34 | - uses: itchyny/s3-cache-action@v1 35 | with: 36 | path: ~/.npm 37 | key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 38 | restore-keys: | 39 | npm-${{ runner.os }}- 40 | bucket-name: ${{ vars.S3_CACHE_BUCKET_NAME }} 41 | ``` 42 | 43 | Refer to [action.yaml](https://github.com/itchyny/s3-cache-action/blob/main/action.yaml) for the documentation of the inputs and outputs. 44 | 45 | ## Differences from actions/cache 46 | 47 | - The action does not have cache scope based on branches, so it may restore caches from a sibling branch. 48 | You can include `${{ github.ref_name }}` in `key` and default branch name in `restore-keys` to emulate the behavior. 49 | - The action restores caches using `key` by exact matching, while `actions/cache` restores by prefix matching. 50 | You can include the same `key` in `restore-keys` for prefix matching. 51 | - The action does not separate caches based on the operating system, especially for Windows. 52 | You can include `${{ runner.os }}` in `key` and `restore-keys`. 53 | - The action uses only gzip compression to simplify implementation, while `actions/cache` uses Zstandard if possible. 54 | The action does not use any external commands, while `actions/cache` relies on `tar`, `gzip`, and `zstd` commands. 55 | 56 | ## npm package 57 | The core implementation of this action is available as an npm package: 58 | [@itchyny/s3-cache-action](https://www.npmjs.com/package/@itchyny/s3-cache-action). 59 | 60 | ```sh 61 | npm install @itchyny/s3-cache-action 62 | ``` 63 | ```typescript 64 | import * as s3 from '@aws-sdk/client-s3'; 65 | import * as cache from '@itchyny/s3-cache-action'; 66 | 67 | const bucketName: string = 'bucket-name'; 68 | const s3Client: s3.S3Client = new s3.S3Client({ 69 | region: process.env.AWS_REGION!, 70 | credentials: { 71 | accessKeyId: process.env.AWS_ACCESS_KEY_ID!, 72 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, 73 | sessionToken: process.env.AWS_SESSION_TOKEN!, 74 | }, 75 | }); 76 | 77 | async function main() { 78 | const saved = await cache.saveCache( 79 | ['*.txt'], 'test-key', bucketName, s3Client, 80 | ); 81 | if (!saved) { 82 | console.log('Cache already exists, skipped saving.'); 83 | } 84 | 85 | const restoredKey = await cache.restoreCache( 86 | ['*.txt'], 'test-key', ['test-'], bucketName, s3Client, 87 | ); 88 | if (restoredKey) { 89 | console.log(`Cache restored with key ${restoredKey}.`); 90 | } 91 | 92 | const foundKey = await cache.lookupCache( 93 | ['*.txt'], 'test-key', ['test-'], bucketName, s3Client, 94 | ); 95 | if (foundKey) { 96 | console.log(`Cache found with key ${foundKey}.`); 97 | } 98 | } 99 | ``` 100 | 101 | ## Bug Tracker 102 | Report bug at [Issues・itchyny/s3-cache-action - GitHub](https://github.com/itchyny/s3-cache-action/issues). 103 | 104 | ## Author 105 | itchyny () 106 | 107 | ## License 108 | This software is released under the MIT License, see LICENSE. 109 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import * as glob from "@actions/glob"; 3 | import * as s3 from "@aws-sdk/client-s3"; 4 | import * as crypto from "crypto"; 5 | import * as fs from "fs"; 6 | import * as tar from "tar"; 7 | import * as tmp from "tmp"; 8 | 9 | import { Client } from "./client"; 10 | 11 | /** 12 | * Save cache to Amazon S3. 13 | * @param paths The paths to cache. 14 | * @param key The cache key. 15 | * @param bucketName The S3 bucket name. 16 | * @param s3Client The S3 client. 17 | * @returns A boolean indicating whether the cache is saved or skipped. 18 | */ 19 | export async function saveCache( 20 | paths: string[], 21 | key: string, 22 | bucketName: string, 23 | s3Client: s3.S3Client, 24 | ): Promise { 25 | const client = new Client(bucketName, s3Client); 26 | const file = fileName(paths); 27 | 28 | // If the cache already exists, do not save the cache. 29 | if (await client.headObject(key, file)) { 30 | core.info(`Cache found in S3 with key ${key}, not saving cache.`); 31 | return false; 32 | } 33 | 34 | // Expand glob patterns of the paths. 35 | const expandedPaths = await glob 36 | .create(paths.join("\n"), { implicitDescendants: false }) 37 | .then((globber) => globber.glob()); 38 | core.debug(`expanded paths: [${expandedPaths.join(", ")}]`); 39 | 40 | // Create a tarball archive. 41 | const archive = archivePath(); 42 | try { 43 | core.debug(`Creating archive ${archive}.`); 44 | await tar.create({ file: archive, gzip: true, preservePaths: true }, expandedPaths); 45 | 46 | // Save the cache to S3. 47 | await client.putObject(key, file, fs.createReadStream(archive)); 48 | core.info(`Cache saved to S3 with key ${key}, ${fileSize(archive)} bytes.`); 49 | return true; 50 | } finally { 51 | try { 52 | core.debug(`Deleting archive ${archive}.`); 53 | fs.unlinkSync(archive); 54 | } catch (error: unknown) { 55 | if (error instanceof Error && !("code" in error && error.code === "ENOENT")) { 56 | core.debug(`Failed to delete archive: ${error}`); 57 | } 58 | } 59 | } 60 | } 61 | 62 | /** 63 | * Restore cache from Amazon S3. 64 | * @param paths The paths to cache. 65 | * @param key The cache key. 66 | * @param restoreKeys The restore keys. 67 | * @param bucketName The S3 bucket name. 68 | * @param s3Client The S3 client. 69 | * @returns The matched key of the cache. 70 | */ 71 | export async function restoreCache( 72 | paths: string[], 73 | key: string, 74 | restoreKeys: string[], 75 | bucketName: string, 76 | s3Client: s3.S3Client, 77 | ): Promise { 78 | const client = new Client(bucketName, s3Client); 79 | const file = fileName(paths); 80 | const archive = archivePath(); 81 | 82 | try { 83 | let restoredKey: string | undefined; 84 | // Restore the cache from S3 with the cache key. 85 | if (await client.getObject(key, file, fs.createWriteStream(archive))) { 86 | restoredKey = key; 87 | } else { 88 | core.info(`Cache not found in S3 with key ${key}.`); 89 | // Restore the cache from S3 with the restore keys. 90 | L: for (const restoreKey of restoreKeys) { 91 | for (const key of await client.listObjects(restoreKey, file)) { 92 | if (await client.getObject(key, file, fs.createWriteStream(archive))) { 93 | restoredKey = key; 94 | break L; 95 | } 96 | } 97 | core.info(`Cache not found in S3 with restore key ${restoreKey}.`); 98 | } 99 | } 100 | 101 | if (restoredKey) { 102 | // Extract the tarball archive. 103 | core.debug(`Extracting archive ${archive}.`); 104 | await tar.extract({ file: archive, preservePaths: true }); 105 | core.info(`Cache restored from S3 with key ${restoredKey}, ${fileSize(archive)} bytes.`); 106 | } 107 | 108 | return restoredKey; 109 | } finally { 110 | try { 111 | core.debug(`Deleting archive ${archive}.`); 112 | fs.unlinkSync(archive); 113 | } catch (error: unknown) { 114 | if (error instanceof Error && !("code" in error && error.code === "ENOENT")) { 115 | core.debug(`Failed to delete archive: ${error}`); 116 | } 117 | } 118 | } 119 | } 120 | 121 | /** 122 | * Lookup cache from Amazon S3. 123 | * @param paths The paths to cache. 124 | * @param key The cache key. 125 | * @param restoreKeys The restore keys. 126 | * @param bucketName The S3 bucket name. 127 | * @param s3Client The S3 client. 128 | * @returns The matched key of the cache. 129 | */ 130 | export async function lookupCache( 131 | paths: string[], 132 | key: string, 133 | restoreKeys: string[], 134 | bucketName: string, 135 | s3Client: s3.S3Client, 136 | ): Promise { 137 | const client = new Client(bucketName, s3Client); 138 | const file = fileName(paths); 139 | 140 | let foundKey: string | undefined; 141 | // Lookup the cache from S3 with the cache key. 142 | if (await client.headObject(key, file)) { 143 | core.info(`Cache found in S3 with key ${key}.`); 144 | foundKey = key; 145 | } else { 146 | core.info(`Cache not found in S3 with key ${key}.`); 147 | // Lookup the cache from S3 with the restore keys. 148 | L: for (const restoreKey of restoreKeys) { 149 | for (const key of await client.listObjects(restoreKey, file)) { 150 | if (await client.headObject(key, file)) { 151 | core.info(`Cache found in S3 with key ${key}, restore key ${restoreKey}.`); 152 | foundKey = key; 153 | break L; 154 | } 155 | } 156 | core.info(`Cache not found in S3 with restore key ${restoreKey}.`); 157 | } 158 | } 159 | 160 | return foundKey; 161 | } 162 | 163 | function fileName(paths: string[]): string { 164 | const hash = crypto.createHash("md5").update(paths.join("\n")).digest("hex"); 165 | return `${hash}.tar.gz`; 166 | } 167 | 168 | function archivePath(): string { 169 | const tmpdir = process.env.RUNNER_TEMP || ""; 170 | return tmp.tmpNameSync({ tmpdir, postfix: ".tar.gz" }); 171 | } 172 | 173 | function fileSize(file: string): number { 174 | return fs.statSync(file).size; 175 | } 176 | -------------------------------------------------------------------------------- /tests/cache.test.ts: -------------------------------------------------------------------------------- 1 | import "aws-sdk-client-mock-jest"; 2 | 3 | import * as s3 from "@aws-sdk/client-s3"; 4 | import * as fs from "fs"; 5 | 6 | import { lookupCache, restoreCache, saveCache } from "../src/cache"; 7 | import { addCleanupFiles, createReadStream, s3Mock } from "./setup"; 8 | 9 | const bucketName = "test-bucket-name"; 10 | const s3Client = new s3.S3Client({ region: "ap-northeast-1" }); 11 | 12 | describe("saveCache", () => { 13 | it("should save the cache successfully", async () => { 14 | s3Mock 15 | .on(s3.HeadObjectCommand, { 16 | Key: "test-key/b61a6d542f9036550ba9c401c80f00ef.tar.gz", 17 | }) 18 | .rejects(new s3.NotFound({ $metadata: {}, message: "Not Found" })) 19 | .on(s3.PutObjectCommand, { 20 | Key: "test-key/b61a6d542f9036550ba9c401c80f00ef.tar.gz", 21 | }) 22 | .resolves({}); 23 | 24 | expect(await saveCache(["tests"], "test-key", bucketName, s3Client)).toBe(true); 25 | expect(s3Mock).toHaveReceivedCommandTimes(s3.HeadObjectCommand, 1); 26 | expect(s3Mock).toHaveReceivedCommandTimes(s3.PutObjectCommand, 1); 27 | }); 28 | 29 | it("should save the cache with glob path", async () => { 30 | s3Mock 31 | .on(s3.HeadObjectCommand, { 32 | Key: "test-key/b31ec5f19793e2b7103acd7336754a1c.tar.gz", 33 | }) 34 | .rejects(new s3.NotFound({ $metadata: {}, message: "Not Found" })) 35 | .on(s3.PutObjectCommand, { 36 | Key: "test-key/b31ec5f19793e2b7103acd7336754a1c.tar.gz", 37 | }) 38 | .resolves({}); 39 | 40 | expect(await saveCache(["*.json"], "test-key", bucketName, s3Client)).toBe(true); 41 | expect(s3Mock).toHaveReceivedCommandTimes(s3.HeadObjectCommand, 1); 42 | expect(s3Mock).toHaveReceivedCommandTimes(s3.PutObjectCommand, 1); 43 | }); 44 | 45 | it("should not save the cache if the cache has been saved already", async () => { 46 | s3Mock 47 | .on(s3.HeadObjectCommand, { 48 | Key: "test-key/b61a6d542f9036550ba9c401c80f00ef.tar.gz", 49 | }) 50 | .resolves({}); 51 | 52 | expect(await saveCache(["tests"], "test-key", bucketName, s3Client)).toBe(false); 53 | }); 54 | }); 55 | 56 | describe("restoreCache", () => { 57 | it("should restore the cache successfully", async () => { 58 | addCleanupFiles("tests/test.txt"); 59 | 60 | s3Mock 61 | .on(s3.GetObjectCommand, { 62 | Key: "test-key/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 63 | }) 64 | .resolves({ 65 | Body: createReadStream("tests/test.tar.gz"), 66 | }); 67 | 68 | expect(await restoreCache(["tests/test.txt"], "test-key", [], bucketName, s3Client)).toBe( 69 | "test-key", 70 | ); 71 | expect(fs.existsSync("tests/test.txt")).toBeTruthy(); 72 | expect(fs.readFileSync("tests/test.txt", "utf-8")).toBe("Hello, world!\n"); 73 | expect(s3Mock).toHaveReceivedCommandTimes(s3.GetObjectCommand, 1); 74 | }); 75 | 76 | it("should restore the cache with restore keys", async () => { 77 | addCleanupFiles("tests/test.txt"); 78 | 79 | s3Mock 80 | .on(s3.GetObjectCommand, { 81 | Key: "test-key/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 82 | }) 83 | .rejects(new s3.NoSuchKey({ $metadata: {}, message: "No Such Key" })) 84 | .on(s3.ListObjectsV2Command, { 85 | Prefix: "test-", 86 | }) 87 | .resolves({ 88 | Contents: [ 89 | { 90 | Key: "test-key1/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 91 | LastModified: new Date("2024-03-18T02:00:00Z"), 92 | }, 93 | { 94 | Key: "test-key2/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 95 | LastModified: new Date("2024-03-18T01:00:00Z"), 96 | }, 97 | { 98 | Key: "test-key3/8c69ddde1da2f30d48825fdfec8a3a4c.tar.gz", 99 | LastModified: new Date("2024-03-18T03:00:00Z"), 100 | }, 101 | ], 102 | }) 103 | .on(s3.GetObjectCommand, { 104 | Key: "test-key1/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 105 | }) 106 | .resolves({ 107 | Body: createReadStream("tests/test.tar.gz"), 108 | }); 109 | 110 | expect( 111 | await restoreCache(["tests/test.txt"], "test-key", ["test-"], bucketName, s3Client), 112 | ).toBe("test-key1"); 113 | expect(fs.existsSync("tests/test.txt")).toBeTruthy(); 114 | expect(fs.readFileSync("tests/test.txt", "utf-8")).toBe("Hello, world!\n"); 115 | expect(s3Mock).toHaveReceivedCommandTimes(s3.GetObjectCommand, 2); 116 | expect(s3Mock).toHaveReceivedCommandTimes(s3.ListObjectsV2Command, 1); 117 | }); 118 | 119 | it("should return undefined if the cache is not found", async () => { 120 | s3Mock 121 | .on(s3.GetObjectCommand, { 122 | Key: "test-key/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 123 | }) 124 | .rejects(new s3.NoSuchKey({ $metadata: {}, message: "No Such Key" })) 125 | .on(s3.ListObjectsV2Command, { 126 | Prefix: "test-", 127 | }) 128 | .resolves({ Contents: [] }); 129 | 130 | expect( 131 | await restoreCache(["tests/test.txt"], "test-key", ["test-"], bucketName, s3Client), 132 | ).toBeUndefined(); 133 | expect(s3Mock).toHaveReceivedCommandTimes(s3.GetObjectCommand, 1); 134 | expect(s3Mock).toHaveReceivedCommandTimes(s3.ListObjectsV2Command, 1); 135 | }); 136 | }); 137 | 138 | describe("lookupCache", () => { 139 | it("should lookup the cache successfully", async () => { 140 | s3Mock 141 | .on(s3.HeadObjectCommand, { 142 | Key: "test-key/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 143 | }) 144 | .resolves({}); 145 | 146 | expect(await lookupCache(["tests/test.txt"], "test-key", [], bucketName, s3Client)).toBe( 147 | "test-key", 148 | ); 149 | expect(s3Mock).toHaveReceivedCommandTimes(s3.HeadObjectCommand, 1); 150 | }); 151 | 152 | it("should lookup the cache with restore keys", async () => { 153 | s3Mock 154 | .on(s3.HeadObjectCommand, { 155 | Key: "test-key/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 156 | }) 157 | .rejects(new s3.NotFound({ $metadata: {}, message: "Not Found" })) 158 | .on(s3.ListObjectsV2Command, { 159 | Prefix: "test-", 160 | }) 161 | .resolves({ 162 | Contents: [ 163 | { 164 | Key: "test-key1/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 165 | LastModified: new Date("2024-03-18T02:00:00Z"), 166 | }, 167 | { 168 | Key: "test-key2/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 169 | LastModified: new Date("2024-03-18T01:00:00Z"), 170 | }, 171 | { 172 | Key: "test-key3/8c69ddde1da2f30d48825fdfec8a3a4c.tar.gz", 173 | LastModified: new Date("2024-03-18T03:00:00Z"), 174 | }, 175 | ], 176 | }) 177 | .on(s3.HeadObjectCommand, { 178 | Key: "test-key1/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 179 | }) 180 | .resolves({}); 181 | 182 | expect(await lookupCache(["tests/test.txt"], "test-key", ["test-"], bucketName, s3Client)).toBe( 183 | "test-key1", 184 | ); 185 | expect(s3Mock).toHaveReceivedCommandTimes(s3.HeadObjectCommand, 2); 186 | expect(s3Mock).toHaveReceivedCommandTimes(s3.ListObjectsV2Command, 1); 187 | }); 188 | 189 | it("should return undefined if the cache is not found", async () => { 190 | s3Mock 191 | .on(s3.HeadObjectCommand, { 192 | Key: "test-key/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 193 | }) 194 | .rejects(new s3.NotFound({ $metadata: {}, message: "Not Found" })) 195 | .on(s3.ListObjectsV2Command, { 196 | Prefix: "test-", 197 | }) 198 | .resolves({ Contents: [] }); 199 | 200 | expect( 201 | await lookupCache(["tests/test.txt"], "test-key", ["test-"], bucketName, s3Client), 202 | ).toBeUndefined(); 203 | expect(s3Mock).toHaveReceivedCommandTimes(s3.HeadObjectCommand, 1); 204 | expect(s3Mock).toHaveReceivedCommandTimes(s3.ListObjectsV2Command, 1); 205 | }); 206 | }); 207 | -------------------------------------------------------------------------------- /tests/restore.test.ts: -------------------------------------------------------------------------------- 1 | import "aws-sdk-client-mock-jest"; 2 | 3 | import * as s3 from "@aws-sdk/client-s3"; 4 | import * as fs from "fs"; 5 | 6 | import { restore } from "../src/restore"; 7 | import { 8 | addCleanupFiles, 9 | createReadStream, 10 | getOutput, 11 | getState, 12 | s3Mock, 13 | setupInputs, 14 | } from "./setup"; 15 | 16 | describe("restore", () => { 17 | it("should restore the cache", async () => { 18 | setupInputs({ path: "tests/test.txt", key: "test-key" }); 19 | addCleanupFiles("tests/test.txt"); 20 | 21 | s3Mock 22 | .on(s3.GetObjectCommand, { 23 | Key: "test-key/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 24 | }) 25 | .resolves({ 26 | Body: createReadStream("tests/test.tar.gz"), 27 | }); 28 | 29 | await restore(); 30 | expect(fs.existsSync("tests/test.txt")).toBeTruthy(); 31 | expect(fs.readFileSync("tests/test.txt", "utf-8")).toBe("Hello, world!\n"); 32 | expect(getState("CACHE_PATH")).toBe("tests/test.txt"); 33 | expect(getState("CACHE_KEY")).toBe("test-key"); 34 | expect(getState("CACHE_HIT")).toBe("true"); 35 | expect(getOutput("cache-hit")).toBe("true"); 36 | expect(s3Mock).toHaveReceivedCommandTimes(s3.GetObjectCommand, 1); 37 | }); 38 | 39 | it("should restore the cache with restore keys", async () => { 40 | setupInputs({ path: "tests/test.txt", key: "test-key", restoreKeys: ["test-"] }); 41 | addCleanupFiles("tests/test.txt"); 42 | 43 | s3Mock 44 | .on(s3.GetObjectCommand, { 45 | Key: "test-key/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 46 | }) 47 | .rejects(new s3.NoSuchKey({ $metadata: {}, message: "No Such Key" })) 48 | .on(s3.ListObjectsV2Command, { 49 | Prefix: "test-", 50 | }) 51 | .resolves({ 52 | Contents: [ 53 | { 54 | Key: "test-key1/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 55 | LastModified: new Date("2024-03-18T02:00:00Z"), 56 | }, 57 | { 58 | Key: "test-key2/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 59 | LastModified: new Date("2024-03-18T01:00:00Z"), 60 | }, 61 | { 62 | Key: "test-key3/8c69ddde1da2f30d48825fdfec8a3a4c.tar.gz", 63 | LastModified: new Date("2024-03-18T03:00:00Z"), 64 | }, 65 | ], 66 | }) 67 | .on(s3.GetObjectCommand, { 68 | Key: "test-key1/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 69 | }) 70 | .resolves({ 71 | Body: createReadStream("tests/test.tar.gz"), 72 | }); 73 | 74 | await restore(); 75 | expect(fs.existsSync("tests/test.txt")).toBeTruthy(); 76 | expect(fs.readFileSync("tests/test.txt", "utf-8")).toBe("Hello, world!\n"); 77 | expect(getState("CACHE_PATH")).toBe("tests/test.txt"); 78 | expect(getState("CACHE_KEY")).toBe("test-key"); 79 | expect(getState("CACHE_HIT")).toBe("false"); 80 | expect(getOutput("cache-hit")).toBe("false"); 81 | expect(s3Mock).toHaveReceivedCommandTimes(s3.GetObjectCommand, 2); 82 | expect(s3Mock).toHaveReceivedCommandTimes(s3.ListObjectsV2Command, 1); 83 | }); 84 | 85 | it("should succeed when cache is not found", async () => { 86 | setupInputs({ path: "tests/test.txt", key: "test-key", restoreKeys: ["test-"] }); 87 | 88 | s3Mock 89 | .on(s3.GetObjectCommand, { 90 | Key: "test-key/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 91 | }) 92 | .rejects(new s3.NoSuchKey({ $metadata: {}, message: "No Such Key" })) 93 | .on(s3.ListObjectsV2Command, { 94 | Prefix: "test-", 95 | }) 96 | .resolves({ Contents: [] }); 97 | 98 | await restore(); 99 | expect(fs.existsSync("tests/test.txt")).toBeFalsy(); 100 | expect(() => getState("CACHE_HIT")).toThrow("State key not found: CACHE_HIT"); 101 | expect(() => getOutput("cache-hit")).toThrow("Output key not found: cache-hit"); 102 | expect(s3Mock).toHaveReceivedCommandTimes(s3.GetObjectCommand, 1); 103 | expect(s3Mock).toHaveReceivedCommandTimes(s3.ListObjectsV2Command, 1); 104 | }); 105 | 106 | it("should fail when fail-on-cache-miss is set true and cache is not found", async () => { 107 | setupInputs({ 108 | path: "tests/test.txt", 109 | key: "test-key", 110 | restoreKeys: ["test-"], 111 | failOnCacheMiss: true, 112 | }); 113 | 114 | s3Mock 115 | .on(s3.GetObjectCommand, { 116 | Key: "test-key/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 117 | }) 118 | .rejects(new s3.NoSuchKey({ $metadata: {}, message: "No Such Key" })) 119 | .on(s3.ListObjectsV2Command, { 120 | Prefix: "test-", 121 | }) 122 | .resolves({ Contents: [] }); 123 | 124 | await expect(restore()).rejects.toThrow( 125 | "Cache not found in S3 with key: test-key, restore keys: [test-]", 126 | ); 127 | expect(fs.existsSync("tests/test.txt")).toBeFalsy(); 128 | expect(() => getState("CACHE_HIT")).toThrow("State key not found: CACHE_HIT"); 129 | expect(() => getOutput("cache-hit")).toThrow("Output key not found: cache-hit"); 130 | expect(s3Mock).toHaveReceivedCommandTimes(s3.GetObjectCommand, 1); 131 | expect(s3Mock).toHaveReceivedCommandTimes(s3.ListObjectsV2Command, 1); 132 | }); 133 | 134 | it("should lookup the cache successfully", async () => { 135 | setupInputs({ path: "tests/test.txt", key: "test-key", lookupOnly: true }); 136 | 137 | s3Mock 138 | .on(s3.HeadObjectCommand, { 139 | Key: "test-key/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 140 | }) 141 | .resolves({}); 142 | 143 | await restore(); 144 | expect(fs.existsSync("tests/test.txt")).toBeFalsy(); 145 | expect(getState("CACHE_PATH")).toBe("tests/test.txt"); 146 | expect(getState("CACHE_KEY")).toBe("test-key"); 147 | expect(getState("CACHE_HIT")).toBe("true"); 148 | expect(getOutput("cache-hit")).toBe("true"); 149 | expect(s3Mock).toHaveReceivedCommandTimes(s3.HeadObjectCommand, 1); 150 | }); 151 | 152 | it("should lookup the cache with restore keys", async () => { 153 | setupInputs({ 154 | path: "tests/test.txt", 155 | key: "test-key", 156 | restoreKeys: ["test-"], 157 | lookupOnly: true, 158 | }); 159 | 160 | s3Mock 161 | .on(s3.HeadObjectCommand, { 162 | Key: "test-key/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 163 | }) 164 | .rejects(new s3.NotFound({ $metadata: {}, message: "Not Found" })) 165 | .on(s3.ListObjectsV2Command, { 166 | Prefix: "test-", 167 | }) 168 | .resolves({ 169 | Contents: [ 170 | { 171 | Key: "test-key1/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 172 | LastModified: new Date("2024-03-18T02:00:00Z"), 173 | }, 174 | { 175 | Key: "test-key2/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 176 | LastModified: new Date("2024-03-18T01:00:00Z"), 177 | }, 178 | { 179 | Key: "test-key3/8c69ddde1da2f30d48825fdfec8a3a4c.tar.gz", 180 | LastModified: new Date("2024-03-18T03:00:00Z"), 181 | }, 182 | ], 183 | }) 184 | .on(s3.HeadObjectCommand, { 185 | Key: "test-key1/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 186 | }) 187 | .resolves({}); 188 | 189 | await restore(); 190 | expect(fs.existsSync("tests/test.txt")).toBeFalsy(); 191 | expect(getState("CACHE_PATH")).toBe("tests/test.txt"); 192 | expect(getState("CACHE_KEY")).toBe("test-key"); 193 | expect(getState("CACHE_HIT")).toBe("false"); 194 | expect(getOutput("cache-hit")).toBe("false"); 195 | expect(s3Mock).toHaveReceivedCommandTimes(s3.HeadObjectCommand, 2); 196 | expect(s3Mock).toHaveReceivedCommandTimes(s3.ListObjectsV2Command, 1); 197 | }); 198 | 199 | it("should succeed when cache is not found on lookup", async () => { 200 | setupInputs({ 201 | path: "tests/test.txt", 202 | key: "test-key", 203 | restoreKeys: ["test-"], 204 | lookupOnly: true, 205 | }); 206 | 207 | s3Mock 208 | .on(s3.HeadObjectCommand, { 209 | Key: "test-key/5ae889e6d39b6deb7b3b9ba1bb15a5f6.tar.gz", 210 | }) 211 | .rejects(new s3.NotFound({ $metadata: {}, message: "Not Found" })) 212 | .on(s3.ListObjectsV2Command, { 213 | Prefix: "test-", 214 | }) 215 | .resolves({ Contents: [] }); 216 | 217 | await restore(); 218 | expect(fs.existsSync("tests/test.txt")).toBeFalsy(); 219 | expect(() => getState("CACHE_HIT")).toThrow("State key not found: CACHE_HIT"); 220 | expect(() => getOutput("cache-hit")).toThrow("Output key not found: cache-hit"); 221 | expect(s3Mock).toHaveReceivedCommandTimes(s3.HeadObjectCommand, 1); 222 | expect(s3Mock).toHaveReceivedCommandTimes(s3.ListObjectsV2Command, 1); 223 | }); 224 | 225 | it("should throw an error when path input is not supplied", async () => { 226 | setupInputs({ path: "", key: "test-key" }); 227 | 228 | await expect(restore()).rejects.toThrow("Input required and not supplied: path"); 229 | expect(() => getState("CACHE_HIT")).toThrow("State key not found: CACHE_HIT"); 230 | expect(() => getOutput("cache-hit")).toThrow("Output key not found: cache-hit"); 231 | expect(s3Mock).not.toHaveReceivedAnyCommand(); 232 | }); 233 | 234 | it("should throw an error when key input is not supplied", async () => { 235 | setupInputs({ path: "tests/test.txt", key: "" }); 236 | 237 | await expect(restore()).rejects.toThrow("Input required and not supplied: key"); 238 | expect(() => getState("CACHE_HIT")).toThrow("State key not found: CACHE_HIT"); 239 | expect(() => getOutput("cache-hit")).toThrow("Output key not found: cache-hit"); 240 | expect(s3Mock).not.toHaveReceivedAnyCommand(); 241 | }); 242 | }); 243 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - v* 9 | pull_request: 10 | 11 | permissions: 12 | contents: read 13 | id-token: write 14 | 15 | defaults: 16 | run: 17 | shell: bash 18 | 19 | jobs: 20 | test: 21 | name: Test 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v6 26 | with: 27 | ref: ${{ github.event.pull_request.head.sha || github.sha }} 28 | token: ${{ secrets.DEPENDABOT_TOKEN || secrets.GITHUB_TOKEN }} 29 | fetch-depth: ${{ github.actor == 'dependabot[bot]' && 2 || 1 }} 30 | - name: Setup Node.js 31 | uses: actions/setup-node@v6 32 | with: 33 | node-version: 20 34 | cache: npm 35 | - name: Install Dependencies 36 | run: npm ci 37 | - name: Run tests 38 | run: npm run test 39 | - name: Check lint 40 | run: npm run lint 41 | - name: Build 42 | run: npm run build 43 | - name: Check for dist changes 44 | id: dist-changes 45 | run: git diff --exit-code 46 | - name: Push dist changes for dependabot 47 | if: github.actor == 'dependabot[bot]' && failure() && 48 | steps.dist-changes.outcome == 'failure' 49 | run: | 50 | git config user.name "$(git show -s --format=%an)" 51 | git config user.email "$(git show -s --format=%ae)" 52 | git commit --all --amend --no-edit 53 | git push --force origin "HEAD:${GITHUB_HEAD_REF}" 54 | - name: Run prepublishOnly 55 | run: npm run prepublishOnly 56 | 57 | test-secrets: 58 | name: Test secrets 59 | outputs: 60 | accessible: ${{ steps.test-secrets.outputs.accessible }} 61 | runs-on: ubuntu-latest 62 | steps: 63 | - name: Test if secrets are available 64 | id: test-secrets 65 | if: env.AWS_ACCESS_KEY_ID 66 | run: echo "accessible=true" >> "$GITHUB_OUTPUT" 67 | env: 68 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 69 | 70 | save-files: 71 | name: Save files 72 | needs: test-secrets 73 | if: needs.test-secrets.outputs.accessible 74 | strategy: 75 | matrix: 76 | os: [ubuntu-latest, macos-latest, windows-latest] 77 | fail-fast: false 78 | runs-on: ${{ matrix.os }} 79 | steps: 80 | - name: Checkout code 81 | uses: actions/checkout@v6 82 | - name: Create test files 83 | run: | 84 | mkdir test-dir 85 | for i in {1..3}; do 86 | echo "$RANDOM" > "test-file-$i" 87 | echo "$RANDOM" > "test-dir/test-$i" 88 | done 89 | echo "${{ github.run_id }}" > ~/test-file 90 | - name: Test action 91 | id: cache 92 | uses: ./ 93 | with: 94 | path: | 95 | test-file-* 96 | test-dir 97 | ~/test-file 98 | key: ${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }} 99 | bucket-name: ${{ vars.BUCKET_NAME }} 100 | aws-region: ${{ vars.AWS_REGION }} 101 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 102 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 103 | - name: Check outputs 104 | run: test -z "${{ steps.cache.outputs.cache-hit }}" 105 | - name: Upload test files 106 | uses: actions/upload-artifact@v5 107 | with: 108 | name: ${{ runner.os }}-test-files 109 | path: test-* 110 | retention-days: 1 111 | 112 | restore-files: 113 | name: Restore files 114 | needs: save-files 115 | strategy: 116 | matrix: 117 | os: [ubuntu-latest, macos-latest, windows-latest] 118 | fail-fast: false 119 | runs-on: ${{ matrix.os }} 120 | steps: 121 | - name: Checkout code 122 | uses: actions/checkout@v6 123 | - name: Login AWS 124 | uses: aws-actions/configure-aws-credentials@v5 125 | with: 126 | aws-region: ${{ vars.AWS_REGION }} 127 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 128 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 129 | - name: Test action 130 | id: cache 131 | uses: ./ 132 | with: 133 | path: | 134 | test-file-* 135 | test-dir 136 | ~/test-file 137 | key: ${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }} 138 | bucket-name: ${{ vars.BUCKET_NAME }} 139 | - name: Check outputs 140 | run: test "${{ steps.cache.outputs.cache-hit }}" == "true" 141 | - name: Download test file 142 | uses: actions/download-artifact@v6 143 | with: 144 | name: ${{ runner.os }}-test-files 145 | path: original 146 | - name: Compare files 147 | run: | 148 | mkdir restored 149 | cp -r test-* restored 150 | diff -r original restored 151 | grep -q -x "${{ github.run_id }}" ~/test-file 152 | 153 | restore-keys: 154 | name: Restore keys 155 | needs: save-files 156 | runs-on: ubuntu-latest 157 | steps: 158 | - name: Checkout code 159 | uses: actions/checkout@v6 160 | - name: Login AWS 161 | uses: aws-actions/configure-aws-credentials@v5 162 | with: 163 | aws-region: ${{ vars.AWS_REGION }} 164 | role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }} 165 | - name: Test action 166 | id: cache 167 | uses: ./ 168 | with: 169 | path: | 170 | test-file-* 171 | test-dir 172 | ~/test-file 173 | key: ${{ runner.os }}-${{ github.run_id }}-0${{ github.run_attempt }} 174 | restore-keys: | 175 | ${{ runner.os }}-${{ github.run_id }}- 176 | bucket-name: ${{ vars.BUCKET_NAME }} 177 | - name: Check outputs 178 | run: test "${{ steps.cache.outputs.cache-hit }}" == "false" 179 | - name: Download test file 180 | uses: actions/download-artifact@v6 181 | with: 182 | name: ${{ runner.os }}-test-files 183 | path: original 184 | - name: Compare files 185 | run: | 186 | mkdir restored 187 | cp -r test-* restored 188 | diff -r original restored 189 | grep -q -x "${{ github.run_id }}" ~/test-file 190 | 191 | lookup-only: 192 | name: Lookup only 193 | needs: save-files 194 | runs-on: ubuntu-latest 195 | steps: 196 | - name: Checkout code 197 | uses: actions/checkout@v6 198 | - name: Login AWS 199 | uses: aws-actions/configure-aws-credentials@v5 200 | with: 201 | aws-region: ${{ vars.AWS_REGION }} 202 | role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }} 203 | - name: Test action 204 | id: cache 205 | uses: ./ 206 | with: 207 | path: | 208 | test-file-* 209 | test-dir 210 | ~/test-file 211 | key: ${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }} 212 | lookup-only: true 213 | bucket-name: ${{ vars.BUCKET_NAME }} 214 | - name: Check outputs 215 | run: test "${{ steps.cache.outputs.cache-hit }}" == "true" 216 | - name: Check file does not exist 217 | run: | 218 | if [[ -f ~/test-file ]]; then 219 | exit 1 220 | fi 221 | 222 | save-only: 223 | name: Save only 224 | needs: test-secrets 225 | if: needs.test-secrets.outputs.accessible 226 | runs-on: ubuntu-latest 227 | steps: 228 | - name: Checkout code 229 | uses: actions/checkout@v6 230 | - name: Create test files 231 | run: | 232 | for i in {1..3}; do 233 | echo "$RANDOM" > "test-$i" 234 | done 235 | - name: Test action 236 | uses: ./save 237 | with: 238 | path: test-* 239 | key: ${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }} 240 | bucket-name: ${{ vars.BUCKET_NAME }} 241 | aws-region: ${{ vars.AWS_REGION }} 242 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 243 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 244 | - name: Upload test files 245 | uses: actions/upload-artifact@v5 246 | with: 247 | name: ${{ runner.os }}-only-test-files 248 | path: test-* 249 | retention-days: 1 250 | 251 | restore-only: 252 | name: Restore only 253 | needs: save-only 254 | runs-on: ubuntu-latest 255 | steps: 256 | - name: Checkout code 257 | uses: actions/checkout@v6 258 | - name: Test action 259 | id: cache 260 | uses: ./restore 261 | with: 262 | path: test-* 263 | key: ${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }} 264 | bucket-name: ${{ vars.BUCKET_NAME }} 265 | aws-region: ${{ vars.AWS_REGION }} 266 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 267 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 268 | - name: Check outputs 269 | run: test "${{ steps.cache.outputs.cache-hit }}" == "true" 270 | - name: Download test file 271 | uses: actions/download-artifact@v6 272 | with: 273 | name: ${{ runner.os }}-only-test-files 274 | path: original 275 | - name: Compare files 276 | run: | 277 | mkdir restored 278 | cp -r test-* restored 279 | diff -r original restored 280 | 281 | lookup-no-hit: 282 | name: Lookup no hit 283 | needs: save-only 284 | runs-on: ubuntu-latest 285 | steps: 286 | - name: Checkout code 287 | uses: actions/checkout@v6 288 | - name: Test action 289 | id: cache 290 | uses: ./restore 291 | with: 292 | path: test-* 293 | key: ${{ runner.os }}-${{ github.run_id }}-0${{ github.run_attempt }} 294 | lookup-only: true 295 | bucket-name: ${{ vars.BUCKET_NAME }} 296 | aws-region: ${{ vars.AWS_REGION }} 297 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 298 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 299 | - name: Check outputs 300 | run: test -z "${{ steps.cache.outputs.cache-hit }}" 301 | 302 | release: 303 | name: Release 304 | needs: 305 | - test 306 | - restore-files 307 | - restore-keys 308 | - lookup-only 309 | - restore-only 310 | - lookup-no-hit 311 | if: startsWith(github.ref, 'refs/tags/v') 312 | permissions: 313 | contents: write 314 | runs-on: ubuntu-latest 315 | steps: 316 | - name: Checkout code 317 | uses: actions/checkout@v6 318 | - name: Setup Node.js 319 | uses: actions/setup-node@v6 320 | with: 321 | node-version: 20 322 | cache: npm 323 | registry-url: https://registry.npmjs.org 324 | always-auth: true 325 | scope: '@itchyny' 326 | - name: Install Dependencies 327 | run: npm ci 328 | - name: Publish 329 | run: npm publish 330 | env: 331 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 332 | - name: Setup release body 333 | run: sed -n '/\[${{ github.ref_name }}\]/,/^$/{//!p}' CHANGELOG.md >release-body.txt 334 | - name: Create release 335 | uses: ncipollo/release-action@v1 336 | with: 337 | name: Release ${{ github.ref_name }} 338 | bodyFile: release-body.txt 339 | - name: Push major version tag 340 | run: git push origin --force "HEAD:refs/tags/${GITHUB_REF_NAME%%.*}" 341 | --------------------------------------------------------------------------------