├── .eslintrc.json ├── .github └── workflows │ └── workflow.yml ├── .gitignore ├── .prettierrc.json ├── .vscode └── launch.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __tests__ └── restore.test.ts ├── action.yml ├── dist ├── restore │ └── index.js └── save │ └── index.js ├── examples.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── cacheHttpClient.ts ├── constants.ts ├── contracts.d.ts ├── restore.ts ├── save.ts └── utils │ ├── actionUtils.ts │ └── testUtils.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { "node": true, "jest": true }, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { "ecmaVersion": 2020, "sourceType": "module" }, 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:import/errors", 10 | "plugin:import/warnings", 11 | "plugin:import/typescript", 12 | "plugin:prettier/recommended", 13 | "prettier/@typescript-eslint" 14 | ], 15 | "plugins": ["@typescript-eslint", "jest"] 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - '**.md' 9 | push: 10 | branches: 11 | - master 12 | paths-ignore: 13 | - '**.md' 14 | 15 | jobs: 16 | test: 17 | name: Test on ${{ matrix.os }} 18 | 19 | strategy: 20 | matrix: 21 | os: [ubuntu-latest, windows-latest, macOS-latest] 22 | 23 | runs-on: ${{ matrix.os }} 24 | 25 | steps: 26 | - uses: actions/checkout@v1 27 | 28 | - uses: actions/setup-node@v1 29 | with: 30 | node-version: '12.x' 31 | 32 | - name: Get npm cache directory 33 | id: npm-cache 34 | run: | 35 | echo "::set-output name=dir::$(npm config get cache)" 36 | 37 | - uses: actions/cache@v1 38 | with: 39 | path: ${{ steps.npm-cache.outputs.dir }} 40 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 41 | restore-keys: | 42 | ${{ runner.os }}-node- 43 | 44 | - run: npm ci 45 | 46 | - name: Prettier Format Check 47 | run: npm run format-check 48 | 49 | - name: ESLint Check 50 | run: npm run lint 51 | 52 | - name: Build & Test 53 | run: npm run test 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __tests__/runner/* 2 | 3 | # comment out in distribution branches 4 | dist/ 5 | 6 | node_modules/ 7 | lib/ 8 | 9 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 10 | # Logs 11 | logs 12 | *.log 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | lerna-debug.log* 17 | 18 | # Diagnostic reports (https://nodejs.org/api/report.html) 19 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 20 | 21 | # Runtime data 22 | pids 23 | *.pid 24 | *.seed 25 | *.pid.lock 26 | 27 | # Directory for instrumented libs generated by jscoverage/JSCover 28 | lib-cov 29 | 30 | # Coverage directory used by tools like istanbul 31 | coverage 32 | *.lcov 33 | 34 | # nyc test coverage 35 | .nyc_output 36 | 37 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 38 | .grunt 39 | 40 | # Bower dependency directory (https://bower.io/) 41 | bower_components 42 | 43 | # node-waf configuration 44 | .lock-wscript 45 | 46 | # Compiled binary addons (https://nodejs.org/api/addons.html) 47 | build/Release 48 | 49 | # Dependency directories 50 | jspm_packages/ 51 | 52 | # TypeScript v1 declaration files 53 | typings/ 54 | 55 | # TypeScript cache 56 | *.tsbuildinfo 57 | 58 | # Optional npm cache directory 59 | .npm 60 | 61 | # Optional eslint cache 62 | .eslintcache 63 | 64 | # Optional REPL history 65 | .node_repl_history 66 | 67 | # Output of 'npm pack' 68 | *.tgz 69 | 70 | # Yarn Integrity file 71 | .yarn-integrity 72 | 73 | # dotenv environment variables file 74 | .env 75 | .env.test 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | 80 | # next.js build output 81 | .next 82 | 83 | # nuxt.js build output 84 | .nuxt 85 | 86 | # vuepress build output 87 | .vuepress/dist 88 | 89 | # Serverless directories 90 | .serverless/ 91 | 92 | # FuseBox cache 93 | .fusebox/ 94 | 95 | # DynamoDB Local files 96 | .dynamodb/ 97 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "arrowParens": "avoid", 10 | "parser": "typescript" 11 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Jest Test", 11 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 12 | "args": ["--runInBand", "--config=${workspaceFolder}/jest.config.js"], 13 | "console": "integratedTerminal", 14 | "internalConsoleOptions": "neverOpen" 15 | }, 16 | ] 17 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at opensource+actions/cache@github.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: https://github.com/actions/cache/fork 4 | [pr]: https://github.com/actions/cache/compare 5 | [style]: https://github.com/styleguide/js 6 | [code-of-conduct]: CODE_OF_CONDUCT.md 7 | 8 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 9 | 10 | Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE). 11 | 12 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 13 | 14 | ## Submitting a pull request 15 | 16 | 1. [Fork][fork] and clone the repository 17 | 2. Configure and install the dependencies: `npm install` 18 | 3. Make sure the tests pass on your machine: `npm run test` 19 | 4. Create a new branch: `git checkout -b my-branch-name` 20 | 5. Make your change, add tests, and make sure the tests still pass 21 | 6. Push to your fork and [submit a pull request][pr] 22 | 7. Pat your self on the back and wait for your pull request to be reviewed and merged. 23 | 24 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 25 | 26 | - Write tests. 27 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 28 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 29 | 30 | ## Resources 31 | 32 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 33 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 34 | - [GitHub Help](https://help.github.com) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2018 GitHub, Inc. and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cache 2 | 3 | This GitHub Action allows caching dependencies and build outputs to improve workflow execution time. 4 | 5 | GitHub Actions status 6 | 7 | ## Documentation 8 | 9 | See ["Caching dependencies to speed up workflows"](https://help.github.com/github/automating-your-workflow-with-github-actions/caching-dependencies-to-speed-up-workflows). 10 | 11 | ## Usage 12 | 13 | ### Pre-requisites 14 | Create a workflow `.yml` file in your repositories `.github/workflows` directory. An [example workflow](#example-workflow) is available below. For more information, reference the GitHub Help Documentation for [Creating a workflow file](https://help.github.com/en/articles/configuring-a-workflow#creating-a-workflow-file). 15 | 16 | ### Inputs 17 | 18 | * `path` - A directory to store and save the cache 19 | * `key` - An explicit key for restoring and saving the cache 20 | * `restore-keys` - An ordered list of keys to use for restoring the cache if no cache hit occurred for key 21 | 22 | ### Outputs 23 | 24 | * `cache-hit` - A boolean value to indicate an exact match was found for the key 25 | 26 | > See [Skipping steps based on cache-hit](#Skipping-steps-based-on-cache-hit) for info on using this output 27 | 28 | ### Example workflow 29 | 30 | ```yaml 31 | name: Caching Primes 32 | 33 | on: push 34 | 35 | jobs: 36 | build: 37 | runs-on: ubuntu-latest 38 | 39 | steps: 40 | - uses: actions/checkout@v1 41 | 42 | - name: Cache Primes 43 | id: cache-primes 44 | uses: actions/cache@v1 45 | with: 46 | path: prime-numbers 47 | key: ${{ runner.os }}-primes 48 | 49 | - name: Generate Prime Numbers 50 | if: steps.cache-primes.outputs.cache-hit != 'true' 51 | run: /generate-primes.sh -d prime-numbers 52 | 53 | - name: Use Prime Numbers 54 | run: /primes.sh -d prime-numbers 55 | ``` 56 | 57 | ## Ecosystem Examples 58 | 59 | See [Examples](examples.md) 60 | 61 | ## Cache Limits 62 | 63 | Individual caches are limited to 400MB and a repository can have up to 2GB of caches. Once the 2GB limit is reached, older caches will be evicted based on when the cache was last accessed. Caches that are not accessed within the last week will also be evicted. 64 | 65 | ## Skipping steps based on cache-hit 66 | 67 | Using the `cache-hit` output, subsequent steps (such as install or build) can be skipped when a cache hit occurs on the key. 68 | 69 | Example: 70 | ```yaml 71 | steps: 72 | - uses: actions/checkout@v1 73 | 74 | - uses: actions/cache@v1 75 | id: cache 76 | with: 77 | path: path/to/dependencies 78 | key: ${{ runner.os }}-${{ hashFiles('**/lockfiles') }} 79 | 80 | - name: Install Dependencies 81 | if: steps.cache.outputs.cache-hit != 'true' 82 | run: /install.sh 83 | ``` 84 | 85 | > Note: The `id` defined in `actions/cache` must match the `id` in the `if` statement (i.e. `steps.[ID].outputs.cache-hit`) 86 | 87 | ## Contributing 88 | We would love for you to contribute to `@actions/cache`, pull requests are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) for more information. 89 | 90 | ## License 91 | The scripts and documentation in this project are released under the [MIT License](LICENSE) 92 | -------------------------------------------------------------------------------- /__tests__/restore.test.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import * as exec from "@actions/exec"; 3 | import * as io from "@actions/io"; 4 | import * as path from "path"; 5 | import * as cacheHttpClient from "../src/cacheHttpClient"; 6 | import { Inputs } from "../src/constants"; 7 | import { ArtifactCacheEntry } from "../src/contracts"; 8 | import run from "../src/restore"; 9 | import * as actionUtils from "../src/utils/actionUtils"; 10 | import * as testUtils from "../src/utils/testUtils"; 11 | 12 | jest.mock("@actions/exec"); 13 | jest.mock("@actions/io"); 14 | jest.mock("../src/utils/actionUtils"); 15 | jest.mock("../src/cacheHttpClient"); 16 | 17 | beforeAll(() => { 18 | jest.spyOn(actionUtils, "resolvePath").mockImplementation(filePath => { 19 | return path.resolve(filePath); 20 | }); 21 | 22 | jest.spyOn(actionUtils, "isExactKeyMatch").mockImplementation( 23 | (key, cacheResult) => { 24 | const actualUtils = jest.requireActual("../src/utils/actionUtils"); 25 | return actualUtils.isExactKeyMatch(key, cacheResult); 26 | } 27 | ); 28 | 29 | jest.spyOn(io, "which").mockImplementation(tool => { 30 | return Promise.resolve(tool); 31 | }); 32 | }); 33 | afterEach(() => { 34 | testUtils.clearInputs(); 35 | }); 36 | 37 | test("restore with no path should fail", async () => { 38 | const failedMock = jest.spyOn(core, "setFailed"); 39 | await run(); 40 | expect(failedMock).toHaveBeenCalledWith( 41 | "Input required and not supplied: path" 42 | ); 43 | }); 44 | 45 | test("restore with no key", async () => { 46 | testUtils.setInput(Inputs.Path, "node_modules"); 47 | const failedMock = jest.spyOn(core, "setFailed"); 48 | await run(); 49 | expect(failedMock).toHaveBeenCalledWith( 50 | "Input required and not supplied: key" 51 | ); 52 | }); 53 | 54 | test("restore with too many keys should fail", async () => { 55 | const key = "node-test"; 56 | const restoreKeys = [...Array(20).keys()].map(x => x.toString()); 57 | testUtils.setInputs({ 58 | path: "node_modules", 59 | key, 60 | restoreKeys 61 | }); 62 | const failedMock = jest.spyOn(core, "setFailed"); 63 | await run(); 64 | expect(failedMock).toHaveBeenCalledWith( 65 | `Key Validation Error: Keys are limited to a maximum of 10.` 66 | ); 67 | }); 68 | 69 | test("restore with large key should fail", async () => { 70 | const key = "foo".repeat(512); // Over the 512 character limit 71 | testUtils.setInputs({ 72 | path: "node_modules", 73 | key 74 | }); 75 | const failedMock = jest.spyOn(core, "setFailed"); 76 | await run(); 77 | expect(failedMock).toHaveBeenCalledWith( 78 | `Key Validation Error: ${key} cannot be larger than 512 characters.` 79 | ); 80 | }); 81 | 82 | test("restore with invalid key should fail", async () => { 83 | const key = "comma,comma"; 84 | testUtils.setInputs({ 85 | path: "node_modules", 86 | key 87 | }); 88 | const failedMock = jest.spyOn(core, "setFailed"); 89 | await run(); 90 | expect(failedMock).toHaveBeenCalledWith( 91 | `Key Validation Error: ${key} cannot contain commas.` 92 | ); 93 | }); 94 | 95 | test("restore with no cache found", async () => { 96 | const key = "node-test"; 97 | testUtils.setInputs({ 98 | path: "node_modules", 99 | key 100 | }); 101 | 102 | const infoMock = jest.spyOn(core, "info"); 103 | const warningMock = jest.spyOn(core, "warning"); 104 | const failedMock = jest.spyOn(core, "setFailed"); 105 | const stateMock = jest.spyOn(core, "saveState"); 106 | 107 | const clientMock = jest.spyOn(cacheHttpClient, "getCacheEntry"); 108 | clientMock.mockImplementation(() => { 109 | return Promise.resolve(null); 110 | }); 111 | 112 | await run(); 113 | 114 | expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key); 115 | expect(warningMock).toHaveBeenCalledTimes(0); 116 | expect(failedMock).toHaveBeenCalledTimes(0); 117 | 118 | expect(infoMock).toHaveBeenCalledWith( 119 | `Cache not found for input keys: ${key}.` 120 | ); 121 | }); 122 | 123 | test("restore with server error should fail", async () => { 124 | const key = "node-test"; 125 | testUtils.setInputs({ 126 | path: "node_modules", 127 | key 128 | }); 129 | 130 | const warningMock = jest.spyOn(core, "warning"); 131 | const failedMock = jest.spyOn(core, "setFailed"); 132 | const stateMock = jest.spyOn(core, "saveState"); 133 | 134 | const clientMock = jest.spyOn(cacheHttpClient, "getCacheEntry"); 135 | clientMock.mockImplementation(() => { 136 | throw new Error("HTTP Error Occurred"); 137 | }); 138 | 139 | const setCacheHitOutputMock = jest.spyOn(actionUtils, "setCacheHitOutput"); 140 | 141 | await run(); 142 | 143 | expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key); 144 | 145 | expect(warningMock).toHaveBeenCalledTimes(1); 146 | expect(warningMock).toHaveBeenCalledWith("HTTP Error Occurred"); 147 | 148 | expect(setCacheHitOutputMock).toHaveBeenCalledTimes(1); 149 | expect(setCacheHitOutputMock).toHaveBeenCalledWith(false); 150 | 151 | expect(failedMock).toHaveBeenCalledTimes(0); 152 | }); 153 | 154 | test("restore with restore keys and no cache found", async () => { 155 | const key = "node-test"; 156 | const restoreKey = "node-"; 157 | testUtils.setInputs({ 158 | path: "node_modules", 159 | key, 160 | restoreKeys: [restoreKey] 161 | }); 162 | 163 | const infoMock = jest.spyOn(core, "info"); 164 | const warningMock = jest.spyOn(core, "warning"); 165 | const failedMock = jest.spyOn(core, "setFailed"); 166 | const stateMock = jest.spyOn(core, "saveState"); 167 | 168 | const clientMock = jest.spyOn(cacheHttpClient, "getCacheEntry"); 169 | clientMock.mockImplementation(() => { 170 | return Promise.resolve(null); 171 | }); 172 | 173 | await run(); 174 | 175 | expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key); 176 | expect(warningMock).toHaveBeenCalledTimes(0); 177 | expect(failedMock).toHaveBeenCalledTimes(0); 178 | 179 | expect(infoMock).toHaveBeenCalledWith( 180 | `Cache not found for input keys: ${key}, ${restoreKey}.` 181 | ); 182 | }); 183 | 184 | test("restore with cache found", async () => { 185 | const key = "node-test"; 186 | const cachePath = path.resolve("node_modules"); 187 | testUtils.setInputs({ 188 | path: "node_modules", 189 | key 190 | }); 191 | 192 | const infoMock = jest.spyOn(core, "info"); 193 | const warningMock = jest.spyOn(core, "warning"); 194 | const failedMock = jest.spyOn(core, "setFailed"); 195 | const stateMock = jest.spyOn(core, "saveState"); 196 | 197 | const cacheEntry: ArtifactCacheEntry = { 198 | cacheKey: key, 199 | scope: "refs/heads/master", 200 | archiveLocation: "www.actionscache.test/download" 201 | }; 202 | const getCacheMock = jest.spyOn(cacheHttpClient, "getCacheEntry"); 203 | getCacheMock.mockImplementation(() => { 204 | return Promise.resolve(cacheEntry); 205 | }); 206 | const tempPath = "/foo/bar"; 207 | 208 | const createTempDirectoryMock = jest.spyOn( 209 | actionUtils, 210 | "createTempDirectory" 211 | ); 212 | createTempDirectoryMock.mockImplementation(() => { 213 | return Promise.resolve(tempPath); 214 | }); 215 | 216 | const archivePath = path.join(tempPath, "cache.tgz"); 217 | const setCacheStateMock = jest.spyOn(actionUtils, "setCacheState"); 218 | const downloadCacheMock = jest.spyOn(cacheHttpClient, "downloadCache"); 219 | 220 | const fileSize = 142; 221 | const getArchiveFileSizeMock = jest 222 | .spyOn(actionUtils, "getArchiveFileSize") 223 | .mockReturnValue(fileSize); 224 | 225 | const mkdirMock = jest.spyOn(io, "mkdirP"); 226 | const execMock = jest.spyOn(exec, "exec"); 227 | const setCacheHitOutputMock = jest.spyOn(actionUtils, "setCacheHitOutput"); 228 | 229 | await run(); 230 | 231 | expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key); 232 | expect(getCacheMock).toHaveBeenCalledWith([key]); 233 | expect(setCacheStateMock).toHaveBeenCalledWith(cacheEntry); 234 | expect(createTempDirectoryMock).toHaveBeenCalledTimes(1); 235 | expect(downloadCacheMock).toHaveBeenCalledWith(cacheEntry, archivePath); 236 | expect(getArchiveFileSizeMock).toHaveBeenCalledWith(archivePath); 237 | expect(mkdirMock).toHaveBeenCalledWith(cachePath); 238 | 239 | const IS_WINDOWS = process.platform === "win32"; 240 | const tarArchivePath = IS_WINDOWS 241 | ? archivePath.replace(/\\/g, "/") 242 | : archivePath; 243 | const tarCachePath = IS_WINDOWS ? cachePath.replace(/\\/g, "/") : cachePath; 244 | const args = IS_WINDOWS ? ["-xz", "--force-local"] : ["-xz"]; 245 | args.push(...["-f", tarArchivePath, "-C", tarCachePath]); 246 | 247 | expect(execMock).toHaveBeenCalledTimes(1); 248 | expect(execMock).toHaveBeenCalledWith(`"tar"`, args); 249 | 250 | expect(setCacheHitOutputMock).toHaveBeenCalledTimes(1); 251 | expect(setCacheHitOutputMock).toHaveBeenCalledWith(true); 252 | 253 | expect(infoMock).toHaveBeenCalledWith(`Cache restored from key: ${key}`); 254 | expect(warningMock).toHaveBeenCalledTimes(0); 255 | expect(failedMock).toHaveBeenCalledTimes(0); 256 | }); 257 | 258 | test("restore with cache found for restore key", async () => { 259 | const key = "node-test"; 260 | const restoreKey = "node-"; 261 | const cachePath = path.resolve("node_modules"); 262 | testUtils.setInputs({ 263 | path: "node_modules", 264 | key, 265 | restoreKeys: [restoreKey] 266 | }); 267 | 268 | const infoMock = jest.spyOn(core, "info"); 269 | const warningMock = jest.spyOn(core, "warning"); 270 | const failedMock = jest.spyOn(core, "setFailed"); 271 | const stateMock = jest.spyOn(core, "saveState"); 272 | 273 | const cacheEntry: ArtifactCacheEntry = { 274 | cacheKey: restoreKey, 275 | scope: "refs/heads/master", 276 | archiveLocation: "www.actionscache.test/download" 277 | }; 278 | const getCacheMock = jest.spyOn(cacheHttpClient, "getCacheEntry"); 279 | getCacheMock.mockImplementation(() => { 280 | return Promise.resolve(cacheEntry); 281 | }); 282 | const tempPath = "/foo/bar"; 283 | 284 | const createTempDirectoryMock = jest.spyOn( 285 | actionUtils, 286 | "createTempDirectory" 287 | ); 288 | createTempDirectoryMock.mockImplementation(() => { 289 | return Promise.resolve(tempPath); 290 | }); 291 | 292 | const archivePath = path.join(tempPath, "cache.tgz"); 293 | const setCacheStateMock = jest.spyOn(actionUtils, "setCacheState"); 294 | const downloadCacheMock = jest.spyOn(cacheHttpClient, "downloadCache"); 295 | 296 | const fileSize = 142; 297 | const getArchiveFileSizeMock = jest 298 | .spyOn(actionUtils, "getArchiveFileSize") 299 | .mockReturnValue(fileSize); 300 | 301 | const mkdirMock = jest.spyOn(io, "mkdirP"); 302 | const execMock = jest.spyOn(exec, "exec"); 303 | const setCacheHitOutputMock = jest.spyOn(actionUtils, "setCacheHitOutput"); 304 | 305 | await run(); 306 | 307 | expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key); 308 | expect(getCacheMock).toHaveBeenCalledWith([key, restoreKey]); 309 | expect(setCacheStateMock).toHaveBeenCalledWith(cacheEntry); 310 | expect(createTempDirectoryMock).toHaveBeenCalledTimes(1); 311 | expect(downloadCacheMock).toHaveBeenCalledWith(cacheEntry, archivePath); 312 | expect(getArchiveFileSizeMock).toHaveBeenCalledWith(archivePath); 313 | expect(mkdirMock).toHaveBeenCalledWith(cachePath); 314 | 315 | const IS_WINDOWS = process.platform === "win32"; 316 | const tarArchivePath = IS_WINDOWS 317 | ? archivePath.replace(/\\/g, "/") 318 | : archivePath; 319 | const tarCachePath = IS_WINDOWS ? cachePath.replace(/\\/g, "/") : cachePath; 320 | const args = IS_WINDOWS ? ["-xz", "--force-local"] : ["-xz"]; 321 | args.push(...["-f", tarArchivePath, "-C", tarCachePath]); 322 | 323 | expect(execMock).toHaveBeenCalledTimes(1); 324 | expect(execMock).toHaveBeenCalledWith(`"tar"`, args); 325 | 326 | expect(setCacheHitOutputMock).toHaveBeenCalledTimes(1); 327 | expect(setCacheHitOutputMock).toHaveBeenCalledWith(false); 328 | 329 | expect(infoMock).toHaveBeenCalledWith( 330 | `Cache restored from key: ${restoreKey}` 331 | ); 332 | expect(warningMock).toHaveBeenCalledTimes(0); 333 | expect(failedMock).toHaveBeenCalledTimes(0); 334 | }); 335 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Always-Cache' 2 | description: 'Fork of actions/cache which always saves cache' 3 | author: 'gerbal' 4 | inputs: 5 | path: 6 | description: 'A directory to store and save the cache' 7 | required: true 8 | key: 9 | description: 'An explicit key for restoring and saving the cache' 10 | required: true 11 | restore-keys: 12 | description: 'An ordered list of keys to use for restoring the cache if no cache hit occurred for key' 13 | required: false 14 | outputs: 15 | cache-hit: 16 | description: 'A boolean value to indicate an exact match was found for the primary key' 17 | runs: 18 | using: 'node12' 19 | main: 'dist/restore/index.js' 20 | post: 'dist/save/index.js' 21 | post-if: 'always()' 22 | branding: 23 | icon: 'archive' 24 | color: 'gray-dark' 25 | -------------------------------------------------------------------------------- /examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | - [C# - Nuget](#c---nuget) 4 | - [Elixir - Mix](#elixir---mix) 5 | - [Go - Modules](#go---modules) 6 | - [Java - Gradle](#java---gradle) 7 | - [Java - Maven](#java---maven) 8 | - [Node - npm](#node---npm) 9 | - [Node - Yarn](#node---yarn) 10 | - [PHP - Composer](#php---composer) 11 | - [Ruby - Gem](#ruby---gem) 12 | - [Rust - Cargo](#rust---cargo) 13 | - [Swift, Objective-C - Carthage](#swift-objective-c---carthage) 14 | - [Swift, Objective-C - CocoaPods](#swift-objective-c---cocoapods) 15 | 16 | ## C# - Nuget 17 | Using [NuGet lock files](https://docs.microsoft.com/nuget/consume-packages/package-references-in-project-files#locking-dependencies): 18 | 19 | ```yaml 20 | - uses: actions/cache@v1 21 | with: 22 | path: ~/.nuget/packages 23 | key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} 24 | restore-keys: | 25 | ${{ runner.os }}-nuget- 26 | ``` 27 | 28 | ## Elixir - Mix 29 | ```yaml 30 | - uses: actions/cache@v1 31 | with: 32 | path: deps 33 | key: ${{ runner.os }}-mix-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 34 | restore-keys: | 35 | ${{ runner.os }}-mix- 36 | ``` 37 | 38 | ## Go - Modules 39 | 40 | ```yaml 41 | - uses: actions/cache@v1 42 | with: 43 | path: ~/go/pkg/mod 44 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 45 | restore-keys: | 46 | ${{ runner.os }}-go- 47 | ``` 48 | 49 | ## Java - Gradle 50 | 51 | ```yaml 52 | - uses: actions/cache@v1 53 | with: 54 | path: ~/.gradle/caches 55 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} 56 | restore-keys: | 57 | ${{ runner.os }}-gradle- 58 | ``` 59 | 60 | ## Java - Maven 61 | 62 | ```yaml 63 | - uses: actions/cache@v1 64 | with: 65 | path: ~/.m2/repository 66 | key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} 67 | restore-keys: | 68 | ${{ runner.os }}-maven- 69 | ``` 70 | 71 | ## Node - npm 72 | 73 | For npm, cache files are stored in `~/.npm` on Posix, or `%AppData%/npm-cache` on Windows. See https://docs.npmjs.com/cli/cache#cache 74 | 75 | >Note: It is not recommended to cache `node_modules`, as it can break across Node versions and won't work with `npm ci` 76 | 77 | ### macOS and Ubuntu 78 | 79 | ```yaml 80 | - uses: actions/cache@v1 81 | with: 82 | path: ~/.npm 83 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 84 | restore-keys: | 85 | ${{ runner.os }}-node- 86 | ``` 87 | 88 | ### Windows 89 | 90 | ```yaml 91 | - uses: actions/cache@v1 92 | with: 93 | path: ~\AppData\Roaming\npm-cache 94 | key: ${{ runner.os }}-node-${{ hashFiles('**\package-lock.json') }} 95 | restore-keys: | 96 | ${{ runner.os }}-node- 97 | ``` 98 | 99 | ### Using multiple systems and `npm config` 100 | 101 | ```yaml 102 | - name: Get npm cache directory 103 | id: npm-cache 104 | run: | 105 | echo "::set-output name=dir::$(npm config get cache)" 106 | - uses: actions/cache@v1 107 | with: 108 | path: ${{ steps.npm-cache.outputs.dir }} 109 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 110 | restore-keys: | 111 | ${{ runner.os }}-node- 112 | ``` 113 | 114 | ## Node - Yarn 115 | 116 | ```yaml 117 | - uses: actions/cache@v1 118 | with: 119 | path: ~/.cache/yarn 120 | key: ${{ runner.os }}-yarn-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }} 121 | restore-keys: | 122 | ${{ runner.os }}-yarn- 123 | ``` 124 | 125 | ## PHP - Composer 126 | 127 | ```yaml 128 | - name: Get Composer Cache Directory 129 | id: composer-cache 130 | run: | 131 | echo "::set-output name=dir::$(composer config cache-files-dir)" 132 | - uses: actions/cache@v1 133 | with: 134 | path: ${{ steps.composer-cache.outputs.dir }} 135 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 136 | restore-keys: | 137 | ${{ runner.os }}-composer- 138 | ``` 139 | 140 | ## Ruby - Gem 141 | 142 | ```yaml 143 | - uses: actions/cache@v1 144 | with: 145 | path: vendor/bundle 146 | key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} 147 | restore-keys: | 148 | ${{ runner.os }}-gem- 149 | ``` 150 | 151 | ## Rust - Cargo 152 | 153 | ```yaml 154 | - name: Cache cargo registry 155 | uses: actions/cache@v1 156 | with: 157 | path: ~/.cargo/registry 158 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 159 | - name: Cache cargo index 160 | uses: actions/cache@v1 161 | with: 162 | path: ~/.cargo/git 163 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 164 | - name: Cache cargo build 165 | uses: actions/cache@v1 166 | with: 167 | path: target 168 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 169 | ``` 170 | 171 | ## Swift, Objective-C - Carthage 172 | 173 | ```yaml 174 | - uses: actions/cache@v1 175 | with: 176 | path: Carthage 177 | key: ${{ runner.os }}-carthage-${{ hashFiles('**/Cartfile.resolved') }} 178 | restore-keys: | 179 | ${{ runner.os }}-carthage- 180 | ``` 181 | 182 | ## Swift, Objective-C - CocoaPods 183 | 184 | ```yaml 185 | - uses: actions/cache@v1 186 | with: 187 | path: Pods 188 | key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }} 189 | restore-keys: | 190 | ${{ runner.os }}-pods- 191 | ``` 192 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | require("nock").disableNetConnect(); 2 | 3 | module.exports = { 4 | clearMocks: true, 5 | moduleFileExtensions: ["js", "ts"], 6 | testEnvironment: "node", 7 | testMatch: ["**/*.test.ts"], 8 | testRunner: "jest-circus/runner", 9 | transform: { 10 | "^.+\\.ts$": "ts-jest" 11 | }, 12 | verbose: true 13 | }; 14 | 15 | const processStdoutWrite = process.stdout.write.bind(process.stdout); 16 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 17 | process.stdout.write = (str, encoding, cb) => { 18 | // Core library will directly call process.stdout.write for commands 19 | // We don't want :: commands to be executed by the runner during tests 20 | if (!str.match(/^::/)) { 21 | return processStdoutWrite(str, encoding, cb); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cache", 3 | "version": "1.0.1", 4 | "private": true, 5 | "description": "Cache dependencies and build outputs", 6 | "main": "dist/restore/index.js", 7 | "scripts": { 8 | "build": "tsc", 9 | "test": "tsc --noEmit && jest --coverage", 10 | "lint": "eslint **/*.ts --cache", 11 | "format": "prettier --write **/*.ts", 12 | "format-check": "prettier --check **/*.ts", 13 | "release": "ncc build -o dist/restore src/restore.ts && ncc build -o dist/save src/save.ts && git add -f dist/" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/actions/cache.git" 18 | }, 19 | "keywords": [ 20 | "actions", 21 | "node", 22 | "cache" 23 | ], 24 | "author": "GitHub", 25 | "license": "MIT", 26 | "dependencies": { 27 | "@actions/core": "^1.2.0", 28 | "@actions/exec": "^1.0.1", 29 | "@actions/io": "^1.0.1", 30 | "typed-rest-client": "^1.5.0", 31 | "uuid": "^3.3.3" 32 | }, 33 | "devDependencies": { 34 | "@types/jest": "^24.0.13", 35 | "@types/nock": "^11.1.0", 36 | "@types/node": "^12.0.4", 37 | "@types/uuid": "^3.4.5", 38 | "@typescript-eslint/eslint-plugin": "^2.7.0", 39 | "@typescript-eslint/parser": "^2.7.0", 40 | "@zeit/ncc": "^0.20.5", 41 | "eslint": "^6.6.0", 42 | "eslint-config-prettier": "^6.5.0", 43 | "eslint-plugin-import": "^2.18.2", 44 | "eslint-plugin-jest": "^23.0.3", 45 | "eslint-plugin-prettier": "^3.1.1", 46 | "jest": "^24.8.0", 47 | "jest-circus": "^24.7.1", 48 | "nock": "^11.7.0", 49 | "prettier": "1.18.2", 50 | "ts-jest": "^24.0.2", 51 | "typescript": "^3.6.4" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/cacheHttpClient.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import * as fs from "fs"; 3 | import { BearerCredentialHandler } from "typed-rest-client/Handlers"; 4 | import { HttpClient } from "typed-rest-client/HttpClient"; 5 | import { IHttpClientResponse } from "typed-rest-client/Interfaces"; 6 | import { IRequestOptions, RestClient } from "typed-rest-client/RestClient"; 7 | import { ArtifactCacheEntry } from "./contracts"; 8 | 9 | function getCacheUrl(): string { 10 | // Ideally we just use ACTIONS_CACHE_URL 11 | const cacheUrl: string = ( 12 | process.env["ACTIONS_CACHE_URL"] || 13 | process.env["ACTIONS_RUNTIME_URL"] || 14 | "" 15 | ).replace("pipelines", "artifactcache"); 16 | if (!cacheUrl) { 17 | throw new Error( 18 | "Cache Service Url not found, unable to restore cache." 19 | ); 20 | } 21 | 22 | core.debug(`Cache Url: ${cacheUrl}`); 23 | return cacheUrl; 24 | } 25 | 26 | function createAcceptHeader(type: string, apiVersion: string): string { 27 | return `${type};api-version=${apiVersion}`; 28 | } 29 | 30 | function getRequestOptions(): IRequestOptions { 31 | const requestOptions: IRequestOptions = { 32 | acceptHeader: createAcceptHeader("application/json", "5.2-preview.1") 33 | }; 34 | 35 | return requestOptions; 36 | } 37 | 38 | export async function getCacheEntry( 39 | keys: string[] 40 | ): Promise { 41 | const cacheUrl = getCacheUrl(); 42 | const token = process.env["ACTIONS_RUNTIME_TOKEN"] || ""; 43 | const bearerCredentialHandler = new BearerCredentialHandler(token); 44 | 45 | const resource = `_apis/artifactcache/cache?keys=${encodeURIComponent( 46 | keys.join(",") 47 | )}`; 48 | 49 | const restClient = new RestClient("actions/cache", cacheUrl, [ 50 | bearerCredentialHandler 51 | ]); 52 | 53 | const response = await restClient.get( 54 | resource, 55 | getRequestOptions() 56 | ); 57 | if (response.statusCode === 204) { 58 | return null; 59 | } 60 | if (response.statusCode !== 200) { 61 | throw new Error(`Cache service responded with ${response.statusCode}`); 62 | } 63 | const cacheResult = response.result; 64 | core.debug(`Cache Result:`); 65 | core.debug(JSON.stringify(cacheResult)); 66 | if (!cacheResult || !cacheResult.archiveLocation) { 67 | throw new Error("Cache not found."); 68 | } 69 | 70 | return cacheResult; 71 | } 72 | 73 | async function pipeResponseToStream( 74 | response: IHttpClientResponse, 75 | stream: NodeJS.WritableStream 76 | ): Promise { 77 | return new Promise(resolve => { 78 | response.message.pipe(stream).on("close", () => { 79 | resolve(); 80 | }); 81 | }); 82 | } 83 | 84 | export async function downloadCache( 85 | cacheEntry: ArtifactCacheEntry, 86 | archivePath: string 87 | ): Promise { 88 | const stream = fs.createWriteStream(archivePath); 89 | const httpClient = new HttpClient("actions/cache"); 90 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 91 | const downloadResponse = await httpClient.get(cacheEntry.archiveLocation!); 92 | await pipeResponseToStream(downloadResponse, stream); 93 | } 94 | 95 | export async function saveCache( 96 | stream: NodeJS.ReadableStream, 97 | key: string 98 | ): Promise { 99 | const cacheUrl = getCacheUrl(); 100 | const token = process.env["ACTIONS_RUNTIME_TOKEN"] || ""; 101 | const bearerCredentialHandler = new BearerCredentialHandler(token); 102 | 103 | const resource = `_apis/artifactcache/cache/${encodeURIComponent(key)}`; 104 | const postUrl = cacheUrl + resource; 105 | 106 | const restClient = new RestClient("actions/cache", undefined, [ 107 | bearerCredentialHandler 108 | ]); 109 | 110 | const requestOptions = getRequestOptions(); 111 | requestOptions.additionalHeaders = { 112 | "Content-Type": "application/octet-stream" 113 | }; 114 | 115 | const response = await restClient.uploadStream( 116 | "POST", 117 | postUrl, 118 | stream, 119 | requestOptions 120 | ); 121 | if (response.statusCode !== 200) { 122 | throw new Error(`Cache service responded with ${response.statusCode}`); 123 | } 124 | 125 | core.info("Cache saved successfully"); 126 | } 127 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export enum Inputs { 2 | Key = "key", 3 | Path = "path", 4 | RestoreKeys = "restore-keys" 5 | } 6 | 7 | export enum Outputs { 8 | CacheHit = "cache-hit" 9 | } 10 | 11 | export enum State { 12 | CacheKey = "CACHE_KEY", 13 | CacheResult = "CACHE_RESULT" 14 | } 15 | -------------------------------------------------------------------------------- /src/contracts.d.ts: -------------------------------------------------------------------------------- 1 | export interface ArtifactCacheEntry { 2 | cacheKey?: string; 3 | scope?: string; 4 | creationTime?: string; 5 | archiveLocation?: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/restore.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import { exec } from "@actions/exec"; 3 | import * as io from "@actions/io"; 4 | import * as path from "path"; 5 | import * as cacheHttpClient from "./cacheHttpClient"; 6 | import { Inputs, State } from "./constants"; 7 | import * as utils from "./utils/actionUtils"; 8 | 9 | async function run(): Promise { 10 | try { 11 | // Validate inputs, this can cause task failure 12 | let cachePath = utils.resolvePath( 13 | core.getInput(Inputs.Path, { required: true }) 14 | ); 15 | core.debug(`Cache Path: ${cachePath}`); 16 | 17 | const primaryKey = core.getInput(Inputs.Key, { required: true }); 18 | core.saveState(State.CacheKey, primaryKey); 19 | 20 | const restoreKeys = core 21 | .getInput(Inputs.RestoreKeys) 22 | .split("\n") 23 | .filter(x => x !== ""); 24 | const keys = [primaryKey, ...restoreKeys]; 25 | 26 | core.debug("Resolved Keys:"); 27 | core.debug(JSON.stringify(keys)); 28 | 29 | if (keys.length > 10) { 30 | core.setFailed( 31 | `Key Validation Error: Keys are limited to a maximum of 10.` 32 | ); 33 | return; 34 | } 35 | for (const key of keys) { 36 | if (key.length > 512) { 37 | core.setFailed( 38 | `Key Validation Error: ${key} cannot be larger than 512 characters.` 39 | ); 40 | return; 41 | } 42 | const regex = /^[^,]*$/; 43 | if (!regex.test(key)) { 44 | core.setFailed( 45 | `Key Validation Error: ${key} cannot contain commas.` 46 | ); 47 | return; 48 | } 49 | } 50 | 51 | try { 52 | const cacheEntry = await cacheHttpClient.getCacheEntry(keys); 53 | if (!cacheEntry) { 54 | core.info( 55 | `Cache not found for input keys: ${keys.join(", ")}.` 56 | ); 57 | return; 58 | } 59 | 60 | let archivePath = path.join( 61 | await utils.createTempDirectory(), 62 | "cache.tgz" 63 | ); 64 | core.debug(`Archive Path: ${archivePath}`); 65 | 66 | // Store the cache result 67 | utils.setCacheState(cacheEntry); 68 | 69 | // Download the cache from the cache entry 70 | await cacheHttpClient.downloadCache(cacheEntry, archivePath); 71 | 72 | const archiveFileSize = utils.getArchiveFileSize(archivePath); 73 | core.debug(`File Size: ${archiveFileSize}`); 74 | 75 | io.mkdirP(cachePath); 76 | 77 | // http://man7.org/linux/man-pages/man1/tar.1.html 78 | // tar [-options] [files or directories which to add into archive] 79 | const args = ["-xz"]; 80 | 81 | const IS_WINDOWS = process.platform === "win32"; 82 | if (IS_WINDOWS) { 83 | args.push("--force-local"); 84 | archivePath = archivePath.replace(/\\/g, "/"); 85 | cachePath = cachePath.replace(/\\/g, "/"); 86 | } 87 | args.push(...["-f", archivePath, "-C", cachePath]); 88 | 89 | const tarPath = await io.which("tar", true); 90 | core.debug(`Tar Path: ${tarPath}`); 91 | 92 | await exec(`"${tarPath}"`, args); 93 | 94 | const isExactKeyMatch = utils.isExactKeyMatch( 95 | primaryKey, 96 | cacheEntry 97 | ); 98 | utils.setCacheHitOutput(isExactKeyMatch); 99 | 100 | core.info( 101 | `Cache restored from key: ${cacheEntry && cacheEntry.cacheKey}` 102 | ); 103 | } catch (error) { 104 | core.warning(error.message); 105 | utils.setCacheHitOutput(false); 106 | } 107 | } catch (error) { 108 | core.setFailed(error.message); 109 | } 110 | } 111 | 112 | run(); 113 | 114 | export default run; 115 | -------------------------------------------------------------------------------- /src/save.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import { exec } from "@actions/exec"; 3 | import * as io from "@actions/io"; 4 | import * as fs from "fs"; 5 | import * as path from "path"; 6 | import * as cacheHttpClient from "./cacheHttpClient"; 7 | import { Inputs, State } from "./constants"; 8 | import * as utils from "./utils/actionUtils"; 9 | 10 | async function run(): Promise { 11 | try { 12 | const state = utils.getCacheState(); 13 | 14 | // Inputs are re-evaluted before the post action, so we want the original key used for restore 15 | const primaryKey = core.getState(State.CacheKey); 16 | if (!primaryKey) { 17 | core.warning(`Error retrieving key from state.`); 18 | return; 19 | } 20 | 21 | if (utils.isExactKeyMatch(primaryKey, state)) { 22 | core.info( 23 | `Cache hit occurred on the primary key ${primaryKey}, not saving cache.` 24 | ); 25 | return; 26 | } 27 | 28 | let cachePath = utils.resolvePath( 29 | core.getInput(Inputs.Path, { required: true }) 30 | ); 31 | core.debug(`Cache Path: ${cachePath}`); 32 | 33 | let archivePath = path.join( 34 | await utils.createTempDirectory(), 35 | "cache.tgz" 36 | ); 37 | core.debug(`Archive Path: ${archivePath}`); 38 | 39 | // http://man7.org/linux/man-pages/man1/tar.1.html 40 | // tar [-options] [files or directories which to add into archive] 41 | const args = ["-cz"]; 42 | const IS_WINDOWS = process.platform === "win32"; 43 | if (IS_WINDOWS) { 44 | args.push("--force-local"); 45 | archivePath = archivePath.replace(/\\/g, "/"); 46 | cachePath = cachePath.replace(/\\/g, "/"); 47 | } 48 | 49 | args.push(...["-f", archivePath, "-C", cachePath, "."]); 50 | 51 | const tarPath = await io.which("tar", true); 52 | core.debug(`Tar Path: ${tarPath}`); 53 | await exec(`"${tarPath}"`, args); 54 | 55 | const fileSizeLimit = 400 * 1024 * 1024; // 400MB 56 | const archiveFileSize = fs.statSync(archivePath).size; 57 | core.debug(`File Size: ${archiveFileSize}`); 58 | if (archiveFileSize > fileSizeLimit) { 59 | core.warning( 60 | `Cache size of ${archiveFileSize} bytes is over the 400MB limit, not saving cache.` 61 | ); 62 | return; 63 | } 64 | 65 | const stream = fs.createReadStream(archivePath); 66 | await cacheHttpClient.saveCache(stream, primaryKey); 67 | } catch (error) { 68 | core.warning(error.message); 69 | } 70 | } 71 | 72 | run(); 73 | 74 | export default run; 75 | -------------------------------------------------------------------------------- /src/utils/actionUtils.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import * as io from "@actions/io"; 3 | import * as fs from "fs"; 4 | import * as os from "os"; 5 | import * as path from "path"; 6 | import * as uuidV4 from "uuid/v4"; 7 | import { Outputs, State } from "../constants"; 8 | import { ArtifactCacheEntry } from "../contracts"; 9 | 10 | // From https://github.com/actions/toolkit/blob/master/packages/tool-cache/src/tool-cache.ts#L23 11 | export async function createTempDirectory(): Promise { 12 | const IS_WINDOWS = process.platform === "win32"; 13 | 14 | let tempDirectory: string = process.env["RUNNER_TEMP"] || ""; 15 | 16 | if (!tempDirectory) { 17 | let baseLocation: string; 18 | if (IS_WINDOWS) { 19 | // On Windows use the USERPROFILE env variable 20 | baseLocation = process.env["USERPROFILE"] || "C:\\"; 21 | } else { 22 | if (process.platform === "darwin") { 23 | baseLocation = "/Users"; 24 | } else { 25 | baseLocation = "/home"; 26 | } 27 | } 28 | tempDirectory = path.join(baseLocation, "actions", "temp"); 29 | } 30 | const dest = path.join(tempDirectory, uuidV4.default()); 31 | await io.mkdirP(dest); 32 | return dest; 33 | } 34 | 35 | export function getArchiveFileSize(path: string): number { 36 | return fs.statSync(path).size; 37 | } 38 | 39 | export function isExactKeyMatch( 40 | key: string, 41 | cacheResult?: ArtifactCacheEntry 42 | ): boolean { 43 | return !!( 44 | cacheResult && 45 | cacheResult.cacheKey && 46 | cacheResult.cacheKey.localeCompare(key, undefined, { 47 | sensitivity: "accent" 48 | }) === 0 49 | ); 50 | } 51 | 52 | export function setCacheState(state: ArtifactCacheEntry): void { 53 | core.saveState(State.CacheResult, JSON.stringify(state)); 54 | } 55 | 56 | export function setCacheHitOutput(isCacheHit: boolean): void { 57 | core.setOutput(Outputs.CacheHit, isCacheHit.toString()); 58 | } 59 | 60 | export function setOutputAndState( 61 | key: string, 62 | cacheResult?: ArtifactCacheEntry 63 | ): void { 64 | setCacheHitOutput(isExactKeyMatch(key, cacheResult)); 65 | // Store the cache result if it exists 66 | cacheResult && setCacheState(cacheResult); 67 | } 68 | 69 | export function getCacheState(): ArtifactCacheEntry | undefined { 70 | const stateData = core.getState(State.CacheResult); 71 | core.debug(`State: ${stateData}`); 72 | return (stateData && JSON.parse(stateData)) as ArtifactCacheEntry; 73 | } 74 | 75 | export function resolvePath(filePath: string): string { 76 | if (filePath[0] === "~") { 77 | const home = os.homedir(); 78 | if (!home) { 79 | throw new Error("Unable to resolve `~` to HOME"); 80 | } 81 | return path.join(home, filePath.slice(1)); 82 | } 83 | 84 | return path.resolve(filePath); 85 | } 86 | -------------------------------------------------------------------------------- /src/utils/testUtils.ts: -------------------------------------------------------------------------------- 1 | import { Inputs } from "../constants"; 2 | 3 | // See: https://github.com/actions/toolkit/blob/master/packages/core/src/core.ts#L67 4 | function getInputName(name: string): string { 5 | return `INPUT_${name.replace(/ /g, "_").toUpperCase()}`; 6 | } 7 | 8 | export function setInput(name: string, value: string): void { 9 | process.env[getInputName(name)] = value; 10 | } 11 | 12 | interface CacheInput { 13 | path: string; 14 | key: string; 15 | restoreKeys?: string[]; 16 | } 17 | 18 | export function setInputs(input: CacheInput): void { 19 | setInput(Inputs.Path, input.path); 20 | setInput(Inputs.Key, input.key); 21 | input.restoreKeys && 22 | setInput(Inputs.RestoreKeys, input.restoreKeys.join("\n")); 23 | } 24 | 25 | export function clearInputs(): void { 26 | delete process.env[getInputName(Inputs.Path)]; 27 | delete process.env[getInputName(Inputs.Key)]; 28 | delete process.env[getInputName(Inputs.RestoreKeys)]; 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 6 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "./lib", /* Redirect output structure to the directory. */ 15 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 18 | // "removeComments": true, /* Do not emit comments to output. */ 19 | // "noEmit": true, /* Do not emit outputs. */ 20 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 21 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 22 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 23 | 24 | /* Strict Type-Checking Options */ 25 | "strict": true, /* Enable all strict type-checking options. */ 26 | "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ 27 | // "strictNullChecks": true, /* Enable strict null checks. */ 28 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 29 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 30 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 31 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 32 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 33 | 34 | /* Additional Checks */ 35 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 36 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 37 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 38 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 39 | 40 | /* Module Resolution Options */ 41 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 42 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 43 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 44 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 45 | // "typeRoots": [], /* List of folders to include type definitions from. */ 46 | // "types": [], /* Type declaration files to be included in compilation. */ 47 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 48 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 49 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 50 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 51 | 52 | /* Source Map Options */ 53 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 54 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 55 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 56 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 57 | 58 | /* Experimental Options */ 59 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 60 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 61 | }, 62 | "exclude": ["node_modules", "**/*.test.ts"] 63 | } 64 | --------------------------------------------------------------------------------