├── .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 |
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 |
--------------------------------------------------------------------------------