Compare commits
427 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19e0016878 | |||
| 2820b17d9d | |||
| 7da3ac6eda | |||
| 2461056696 | |||
| c4f5ce2665 | |||
| 91d3933eb5 | |||
| a6bf8726aa | |||
| ae9272d5cb | |||
| f481b8c8dc | |||
| 59851786d4 | |||
| 37e09c586f | |||
| 12c01ac203 | |||
| bbab4bec57 | |||
| 672c88ec4b | |||
| a103f5eefe | |||
| 457303960f | |||
| 463b49d872 | |||
| a91ee0b497 | |||
| 94e340bfb1 | |||
| 599c6164e8 | |||
| b8261b0fb0 | |||
| aeb16eeca1 | |||
| 7a11743b35 | |||
| fe92749762 | |||
| 0e8edb0780 | |||
| 7d1daaf15e | |||
| 40ec298d4b | |||
| 787b2cf270 | |||
| 8e32b1fca3 | |||
| d9a2c5a9f9 | |||
| e6e7b6156f | |||
| f3de1e53d6 | |||
| 703d5ac24a | |||
| 97f21173cc | |||
| ce1bf116fc | |||
| 9ba9ae31a9 | |||
| 94ab8de5f3 | |||
| d47e0bac60 | |||
| 1f4b3fac06 | |||
| 8d92c9c903 | |||
| e45a26f771 | |||
| a3849b77ae | |||
| eb06c21794 | |||
| 0db3029fcf | |||
| e6e29846f2 | |||
| 7c15bf6f40 | |||
| bc713ab90d | |||
| a9d266bb7c | |||
| cf3dd065b8 | |||
| 6ec51745ad | |||
| 0f91c9c203 | |||
| e18b2d8a33 | |||
| 4fd425926c | |||
| 83dffb7746 | |||
| 1d1d5456e3 | |||
| 9e06993ffc | |||
| 3630ea6eed | |||
| c2d3089f83 | |||
| 652109d32c | |||
| f2aa430c9d | |||
| d2b7d85e7c | |||
| 409d616a6e | |||
| e3c2a88bbf | |||
| ea21da6993 | |||
| c6005c2a3c | |||
| 1589a5c066 | |||
| d3801d332c | |||
| 5804607845 | |||
| c26f803662 | |||
| 6c1f9eaae8 | |||
| 412417d0b0 | |||
| 71a6fceb8c | |||
| 34577b269e | |||
| 06c3c38ef2 | |||
| 03d6c2479c | |||
| 411e8fa448 | |||
| 2c09aaef3b | |||
| b2d865f180 | |||
| 56146a6713 | |||
| 74f24b41d1 | |||
| c0b323a0bb | |||
| 83db1b8e43 | |||
| 5b2351aebf | |||
| f5024e4e97 | |||
| 4ea08312c6 | |||
| 5e9bcaca7c | |||
| af2d2ff198 | |||
| 3d46598e70 | |||
| 894a0490f9 | |||
| 86fe4abd8e | |||
| b228732644 | |||
| 2a4f3544ad | |||
| c23fe4b81f | |||
| 034d154f88 | |||
| ccfa36f304 | |||
| 2afea665ed | |||
| e96dc8a69a | |||
| b8c50aa82d | |||
| 24685611e2 | |||
| 80d992795c | |||
| b9de68a590 | |||
| 1d61e5fb19 | |||
| 4abb5a2ae0 | |||
| 6b18932b86 | |||
| 56c460630a | |||
| e1a991ffb7 | |||
| cc9ec0424e | |||
| c91bdbadbf | |||
| 23811ac52f | |||
| e559a15ca6 | |||
| 816c1b3760 | |||
| 819157bf87 | |||
| 86102e88e9 | |||
| e4c071ba19 | |||
| b9d1dd898e | |||
| aaac0e6c98 | |||
| 9443e26349 | |||
| 5a0405df4e | |||
| 0e707aeabc | |||
| 7441dc5e59 | |||
| 1ddac5e02f | |||
| 55484166d8 | |||
| 7181b913f5 | |||
| 81cd5a5c2e | |||
| a735d9bcd4 | |||
| 4b348086a9 | |||
| 436cf8d6ea | |||
| e0aadb573c | |||
| 62a66a8ce9 | |||
| 0c58e4113e | |||
| 9366237c90 | |||
| b36e70495f | |||
| 8423354d7d | |||
| abcca5a0b2 | |||
| b2e1c39c92 | |||
| 8e8a93deae | |||
| 738c849e89 | |||
| 9b58167dc9 | |||
| ffb7e3e14e | |||
| ce378c4cec | |||
| 3e257b0745 | |||
| e5e4491ac5 | |||
| 1bc93f3cdf | |||
| 4fbc5c941a | |||
| ac778acad2 | |||
| 192c26f865 | |||
| 295cbcc4da | |||
| b00a9fd033 | |||
| 4df45177e4 | |||
| 33f1d64363 | |||
| ebe4ac336f | |||
| 94de2cf6d4 | |||
| 64c334f0e5 | |||
| e1bb04bace | |||
| 0388e62759 | |||
| e6257f1117 | |||
| b7db7552c9 | |||
| 2c50af36e2 | |||
| aac665d186 | |||
| bc4be50597 | |||
| 0982f1da89 | |||
| ed96e21792 | |||
| 2ae31879b7 | |||
| 14d8f65f10 | |||
| f47a9aff5e | |||
| ce68daa10e | |||
| a57a4fe011 | |||
| 6c9b023c1b | |||
| 0be752bc46 | |||
| 5c5e91f040 | |||
| 846a0af6ec | |||
| 4b6b45fe18 | |||
| 98a4069558 | |||
| 6e888c882e | |||
| c202c38407 | |||
| d543359fab | |||
| 8bd9e29d3c | |||
| 63c66cf07e | |||
| 556b1c57e7 | |||
| a4276ac40f | |||
| c7340e91af | |||
| d714ea08d6 | |||
| 3fd7f664a6 | |||
| 30995490f2 | |||
| 4beda9cbc0 | |||
| ba462956ea | |||
| cf5d2b8fac | |||
| f9d38b0015 | |||
| 23cfbb3484 | |||
| 83becb7900 | |||
| ef888588c1 | |||
| f05c04b173 | |||
| 518f480528 | |||
| 90be12a59c | |||
| fe1ee8b6b4 | |||
| c89375df9f | |||
| 7cb82599d4 | |||
| 8be69a26ed | |||
| 970264135a | |||
| e5e69a3171 | |||
| 567598fdd7 | |||
| d8b119ca22 | |||
| a438f61f94 | |||
| 388d774221 | |||
| 9b309c5a32 | |||
| 01e1ff7bc0 | |||
| 74ff60c561 | |||
| e98bae803b | |||
| dd553d68ce | |||
| 74dd6f6817 | |||
| 83bca5cb13 | |||
| 2a37ee752b | |||
| ec95a9b114 | |||
| 67cb82d99b | |||
| da6701aea9 | |||
| 593bc7061c | |||
| 120202a68c | |||
| c5278cdd08 | |||
| 46231a7da3 | |||
| 9b7bcb1567 | |||
| 00282d6145 | |||
| b5f31bb5a2 | |||
| 41c667327d | |||
| 2fd9a80bc1 | |||
| ebfda315a5 | |||
| fe93288f85 | |||
| 2d6e5ecd7c | |||
| 49cd1ccc3f | |||
| 3cf35dbd46 | |||
| 845770f824 | |||
| c1bb3fb679 | |||
| 347d2e2a35 | |||
| a78bb30ca0 | |||
| 1fc4ec3274 | |||
| 91842768bd | |||
| 1d5b16aa38 | |||
| 197f5a13a9 | |||
| 8263c4d15d | |||
| 4ee0048304 | |||
| d618dc457e | |||
| 558edc0a3b | |||
| 92b210aced | |||
| 6f6f4e7588 | |||
| 10a3934663 | |||
| 6421989639 | |||
| 07b91eafe5 | |||
| b9fefecf57 | |||
| 9aecf41d21 | |||
| da52b35800 | |||
| 1e0f6285e5 | |||
| dd4e856a4e | |||
| a70804595b | |||
| eb7ed88d77 | |||
| 500d0b42fe | |||
| 82efa3d285 | |||
| 2abc7c46f8 | |||
| e48f1d0c54 | |||
| aa676f3cc7 | |||
| 925ae6978b | |||
| e73063a93c | |||
| c4ae214c26 | |||
| 07242b37a4 | |||
| 01aceeaad6 | |||
| 3d29fb91d1 | |||
| 35e5aac523 | |||
| a3c696e88e | |||
| 91b7bf978c | |||
| b68735e060 | |||
| d5c547c19f | |||
| 9e285cc3fa | |||
| 3f95e2ea4f | |||
| fccc5ee6e6 | |||
| 3d61fe8000 | |||
| 9387bd7ded | |||
| 3e2837ddce | |||
| 3048a9d72c | |||
| 91f9153ca8 | |||
| eef3e92175 | |||
| ed87cc6ce3 | |||
| af45ad8eaa | |||
| 367a6c2423 | |||
| 8f2bd5d713 | |||
| 7654d97eb6 | |||
| 0b2505c754 | |||
| e3549a9c58 | |||
| c5d1911357 | |||
| f8a69bc473 | |||
| 03eca1b0c7 | |||
| 4b12bd3649 | |||
| 6cd8286138 | |||
| 3abbc6c24c | |||
| d594f1e4b3 | |||
| 4a2602dd58 | |||
| 745f129332 | |||
| 7e7e8d4206 | |||
| daa24d7958 | |||
| bda035c74d | |||
| 79acd5bac4 | |||
| b602df7c05 | |||
| 80a66f3298 | |||
| 7756e7c4cb | |||
| 6d774fcb59 | |||
| f05c940e43 | |||
| a71585a450 | |||
| 76ac2fcd59 | |||
| b463992869 | |||
| 39b9640642 | |||
| f0a876ab8b | |||
| 58406447b5 | |||
| 087191dabd | |||
| 862c4e9db4 | |||
| d1abf7dc74 | |||
| 475192a0c3 | |||
| c07c5fc410 | |||
| b820a0ff59 | |||
| 72dfadb0c3 | |||
| edee7cde32 | |||
| 6295f5d25b | |||
| 339dd63bec | |||
| d27bf857e6 | |||
| ec5c955c0a | |||
| 302a5b31d8 | |||
| ab2b23c50d | |||
| 70a01b86d3 | |||
| ff80a82f7c | |||
| 0fc0befe24 | |||
| 7d95d2cec9 | |||
| c42d30607b | |||
| ac58d176ba | |||
| 518ef1b79e | |||
| a502af8759 | |||
| 5905c6b5c1 | |||
| 5e37db2c2b | |||
| d496b07cc0 | |||
| 7a2eceac36 | |||
| fcb8c4ca79 | |||
| 4a793fd385 | |||
| 15e2399826 | |||
| 39a1ec60b2 | |||
| eafa9d39d3 | |||
| daf8bb0060 | |||
| 37f5a85219 | |||
| d1a6612b14 | |||
| 6fcdd6ab0d | |||
| 45a3c7bf81 | |||
| cdd4e107a6 | |||
| 88062ec473 | |||
| 4df5abb3ee | |||
| e19e4261da | |||
| e9b0746ee3 | |||
| 7932c147a0 | |||
| 45d2019161 | |||
| e2eeb0a784 | |||
| 6ce349e08c | |||
| 27f76dfe1a | |||
| 60145e408c | |||
| 8b45e1e356 | |||
| ea81280a4d | |||
| f0b00fd201 | |||
| 4564768940 | |||
| a31b7eca9e | |||
| 9167ce1f3a | |||
| 11601c0d2d | |||
| b9414eecb3 | |||
| 243a8bba07 | |||
| c5e1af5dc3 | |||
| c9af6bb1b3 | |||
| bf4ce74a0f | |||
| db21627995 | |||
| bb2f39337d | |||
| dc4b4dab1d | |||
| 8df94d9879 | |||
| c5035362ab | |||
| 439eaced07 | |||
| 51dc07a106 | |||
| 36b8c66aec | |||
| aa29345ae8 | |||
| e1a7863be6 | |||
| c507914181 | |||
| a65bca60a1 | |||
| a1b068ec31 | |||
| 6e33c78c3d | |||
| 9ac66375a0 | |||
| ddd04b6997 | |||
| 566ea66979 | |||
| 0d74e9080a | |||
| 8dc2d6eb6a | |||
| 3bd746139f | |||
| f915ace085 | |||
| 1bafbed467 | |||
| 98549fbf21 | |||
| b33912b7cc | |||
| cac7db2d19 | |||
| 1c367e0a26 | |||
| 09e59b9a5c | |||
| fecf6cdd59 | |||
| 2b97eb3192 | |||
| ed490dc20d | |||
| 3491e2eeea | |||
| 208fa83feb | |||
| 393feda10a | |||
| d972090333 | |||
| fbdf27470c | |||
| ff45a53422 | |||
| 15fef78171 | |||
| dd046652c3 | |||
| cf3d93512b | |||
| 3512925c1c | |||
| e76decaf8a | |||
| 8afb976445 | |||
| b05573d945 | |||
| 634dc61da2 | |||
| aad34ab0bc | |||
| fac664b5d0 | |||
| 74236358e6 | |||
| ac7b0e436e | |||
| 440a06ef56 | |||
| 547a77cf75 | |||
| a6966e3148 | |||
| 92488b8ab2 | |||
| f628f161c4 | |||
| ea2465fe63 | |||
| 770dc3a982 | |||
| 418978c2e0 | |||
| fc00528337 | |||
| bd9017e99f | |||
| de122731f3 |
+2
-1
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
packages/*/node_modules/
|
||||
packages/*/lib/
|
||||
packages/*/lib/
|
||||
packages/glob/__tests__/_temp
|
||||
+61
-13
@@ -1,6 +1,13 @@
|
||||
{
|
||||
"plugins": ["jest", "@typescript-eslint"],
|
||||
"extends": ["plugin:github/es6"],
|
||||
"plugins": [
|
||||
"jest",
|
||||
"@typescript-eslint",
|
||||
"prettier"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:github/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 9,
|
||||
@@ -8,21 +15,61 @@
|
||||
"project": "./tsconfig.eslint.json"
|
||||
},
|
||||
"rules": {
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
],
|
||||
"eslint-comments/no-use": "off",
|
||||
"github/no-then": "off",
|
||||
"import/no-namespace": "off",
|
||||
"no-shadow": "off",
|
||||
"no-unused-vars": "off",
|
||||
"i18n-text/no-en": "off",
|
||||
"filenames/match-regex": "off",
|
||||
"import/no-commonjs": "off",
|
||||
"import/named": "off",
|
||||
"no-sequences": "off",
|
||||
"import/no-unresolved": "off",
|
||||
"no-undef": "off",
|
||||
"no-only-tests/no-only-tests": "off",
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}],
|
||||
"@typescript-eslint/explicit-member-accessibility": [
|
||||
"error",
|
||||
{
|
||||
"accessibility": "no-public"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-require-imports": "error",
|
||||
"@typescript-eslint/array-type": "error",
|
||||
"@typescript-eslint/await-thenable": "error",
|
||||
"@typescript-eslint/ban-ts-ignore": "error",
|
||||
"@typescript-eslint/ban-ts-comment": "error",
|
||||
"camelcase": "off",
|
||||
"@typescript-eslint/camelcase": "off",
|
||||
"@typescript-eslint/class-name-casing": "error",
|
||||
"@typescript-eslint/explicit-function-return-type": ["error", {"allowExpressions": true}],
|
||||
"@typescript-eslint/func-call-spacing": ["error", "never"],
|
||||
"@typescript-eslint/generic-type-naming": ["error", "^[A-Z][A-Za-z]*$"],
|
||||
"@typescript-eslint/consistent-type-assertions": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": [
|
||||
"error",
|
||||
{
|
||||
"allowExpressions": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/func-call-spacing": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"@typescript-eslint/naming-convention": [
|
||||
"error",
|
||||
{
|
||||
"format": null,
|
||||
"filter": {
|
||||
// you can expand this regex as you find more cases that require quoting that you want to allow
|
||||
"regex": "^[A-Z][A-Za-z]*$",
|
||||
"match": true
|
||||
},
|
||||
"selector": "memberLike"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-array-constructor": "error",
|
||||
"@typescript-eslint/no-empty-interface": "error",
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
@@ -32,7 +79,6 @@
|
||||
"@typescript-eslint/no-misused-new": "error",
|
||||
"@typescript-eslint/no-namespace": "error",
|
||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||
"@typescript-eslint/no-object-literal-type-assertion": "error",
|
||||
"@typescript-eslint/no-unnecessary-qualifier": "error",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
||||
"@typescript-eslint/no-useless-constructor": "error",
|
||||
@@ -40,19 +86,21 @@
|
||||
"@typescript-eslint/prefer-for-of": "warn",
|
||||
"@typescript-eslint/prefer-function-type": "warn",
|
||||
"@typescript-eslint/prefer-includes": "error",
|
||||
"@typescript-eslint/prefer-interface": "error",
|
||||
"@typescript-eslint/prefer-string-starts-ends-with": "error",
|
||||
"@typescript-eslint/promise-function-async": "error",
|
||||
"@typescript-eslint/require-array-sort-compare": "error",
|
||||
"@typescript-eslint/restrict-plus-operands": "error",
|
||||
"semi": "off",
|
||||
"@typescript-eslint/semi": ["error", "never"],
|
||||
"@typescript-eslint/semi": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"@typescript-eslint/type-annotation-spacing": "error",
|
||||
"@typescript-eslint/unbound-method": "error"
|
||||
},
|
||||
"ignorePatterns": "packages/glob/__tests__/_temp/**/",
|
||||
"env": {
|
||||
"node": true,
|
||||
"es6": true,
|
||||
"jest/globals": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ Note that before a PR will be accepted, you must ensure:
|
||||
|
||||
### Useful Scripts
|
||||
|
||||
- `npm run bootstrap` This runs `lerna bootstrap` which will install dependencies in this repository's packages and cross-link packages where necessary.
|
||||
- `npm run bootstrap` This runs `lerna exec -- npm install` which will install dependencies in this repository's packages and cross-link packages where necessary.
|
||||
- `npm run build` This compiles TypeScript code in each package (this is especially important if one package relies on changes in another when you're running tests). This is just an alias for `lerna run tsc`.
|
||||
- `npm run format` This checks that formatting has been applied with Prettier.
|
||||
- `npm test` This runs all Jest tests in all packages in this repository.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Temporarily disabled while v2.0.0 of @actions/artifact is under development
|
||||
|
||||
name: artifact-unit-tests
|
||||
on:
|
||||
push:
|
||||
@@ -22,12 +24,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set Node.js 12.x
|
||||
uses: actions/setup-node@v1
|
||||
- name: Set Node.js 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 12.x
|
||||
node-version: 16.x
|
||||
|
||||
# In order to upload & download artifacts from a shell script, certain env variables need to be set that are only available in the
|
||||
# node context. This runs a local action that gets and sets the necessary env variables that are needed
|
||||
@@ -44,24 +46,27 @@ jobs:
|
||||
npm ci
|
||||
npm run tsc
|
||||
working-directory: packages/artifact
|
||||
|
||||
|
||||
- name: Set artifact file contents
|
||||
shell: bash
|
||||
run: |
|
||||
echo "non-gzip-artifact-content=hello" >> $GITHUB_ENV
|
||||
echo "gzip-artifact-content=Some large amount of text that has a compression ratio that is greater than 100%. If greater than 100%, gzip is used to upload the file" >> $GITHUB_ENV
|
||||
echo "empty-artifact-content=_EMPTY_" >> $GITHUB_ENV
|
||||
|
||||
- name: Create files that will be uploaded
|
||||
run: |
|
||||
mkdir artifact-path
|
||||
echo ${{ env.non-gzip-artifact-content }} > artifact-path/world.txt
|
||||
echo ${{ env.gzip-artifact-content }} > artifact-path/gzip.txt
|
||||
mkdir artifact-path
|
||||
echo '${{ env.non-gzip-artifact-content }}' > artifact-path/world.txt
|
||||
echo '${{ env.gzip-artifact-content }}' > artifact-path/gzip.txt
|
||||
touch artifact-path/empty.txt
|
||||
|
||||
# We're using node -e to call the functions directly available in the @actions/artifact package
|
||||
- name: Upload artifacts using uploadArtifact()
|
||||
run: |
|
||||
node -e "Promise.resolve(require('./packages/artifact/lib/artifact-client').create().uploadArtifact('my-artifact-1',['artifact-path/world.txt'], '${{ github.workspace }}'))"
|
||||
node -e "Promise.resolve(require('./packages/artifact/lib/artifact-client').create().uploadArtifact('my-artifact-2',['artifact-path/gzip.txt'], '${{ github.workspace }}'))"
|
||||
node -e "Promise.resolve(require('./packages/artifact/lib/artifact-client').create().uploadArtifact('my-artifact-1',['artifact-path/world.txt'], process.argv[1]))" "${{ github.workspace }}"
|
||||
node -e "Promise.resolve(require('./packages/artifact/lib/artifact-client').create().uploadArtifact('my-artifact-2',['artifact-path/gzip.txt'], process.argv[1]))" "${{ github.workspace }}"
|
||||
node -e "Promise.resolve(require('./packages/artifact/lib/artifact-client').create().uploadArtifact('my-artifact-3',['artifact-path/empty.txt'], process.argv[1]))" "${{ github.workspace }}"
|
||||
|
||||
- name: Download artifacts using downloadArtifact()
|
||||
run: |
|
||||
@@ -69,12 +74,15 @@ jobs:
|
||||
node -e "Promise.resolve(require('./packages/artifact/lib/artifact-client').create().downloadArtifact('my-artifact-1','artifact-1-directory'))"
|
||||
mkdir artifact-2-directory
|
||||
node -e "Promise.resolve(require('./packages/artifact/lib/artifact-client').create().downloadArtifact('my-artifact-2','artifact-2-directory'))"
|
||||
|
||||
mkdir artifact-3-directory
|
||||
node -e "Promise.resolve(require('./packages/artifact/lib/artifact-client').create().downloadArtifact('my-artifact-3','artifact-3-directory'))"
|
||||
|
||||
- name: Verify downloadArtifact()
|
||||
shell: bash
|
||||
run: |
|
||||
packages/artifact/__tests__/test-artifact-file.sh "artifact-1-directory/artifact-path/world.txt" "${{ env.non-gzip-artifact-content }}"
|
||||
packages/artifact/__tests__/test-artifact-file.sh "artifact-2-directory/artifact-path/gzip.txt" "${{ env.gzip-artifact-content }}"
|
||||
packages/artifact/__tests__/test-artifact-file.sh "artifact-3-directory/artifact-path/empty.txt" "${{ env.empty-artifact-content }}"
|
||||
|
||||
- name: Download artifacts using downloadAllArtifacts()
|
||||
run: |
|
||||
@@ -85,4 +93,5 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
packages/artifact/__tests__/test-artifact-file.sh "multi-artifact-directory/my-artifact-1/artifact-path/world.txt" "${{ env.non-gzip-artifact-content }}"
|
||||
packages/artifact/__tests__/test-artifact-file.sh "multi-artifact-directory/my-artifact-2/artifact-path/gzip.txt" "${{ env.gzip-artifact-content }}"
|
||||
packages/artifact/__tests__/test-artifact-file.sh "multi-artifact-directory/my-artifact-2/artifact-path/gzip.txt" "${{ env.gzip-artifact-content }}"
|
||||
packages/artifact/__tests__/test-artifact-file.sh "multi-artifact-directory/my-artifact-3/artifact-path/empty.txt" "${{ env.empty-artifact-content }}"
|
||||
|
||||
@@ -18,12 +18,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set Node.js 12.x
|
||||
uses: actions/setup-node@v1
|
||||
- name: Set Node.js 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 12.x
|
||||
node-version: 16.x
|
||||
|
||||
- name: npm install
|
||||
run: npm install
|
||||
@@ -31,8 +31,8 @@ jobs:
|
||||
- name: Bootstrap
|
||||
run: npm run bootstrap
|
||||
|
||||
# - name: audit tools #disabled while we wait for https://github.com/actions/toolkit/issues/539
|
||||
# run: npm audit --audit-level=moderate
|
||||
- name: audit tools (without allow-list)
|
||||
run: npm audit --audit-level=moderate
|
||||
|
||||
- name: audit packages
|
||||
run: npm run audit-all
|
||||
|
||||
@@ -22,12 +22,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set Node.js 12.x
|
||||
uses: actions/setup-node@v1
|
||||
- name: Set Node.js 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 12.x
|
||||
node-version: 16.x
|
||||
|
||||
# In order to save & restore cache from a shell script, certain env variables need to be set that are only available in the
|
||||
# node context. This runs a local action that gets and sets the necessary env variables that are needed
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: cache-windows-bsd-unit-tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- shell: bash
|
||||
run: |
|
||||
rm "C:\Program Files\Git\usr\bin\tar.exe"
|
||||
|
||||
- name: Set Node.js 16.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16.x
|
||||
|
||||
# In order to save & restore cache from a shell script, certain env variables need to be set that are only available in the
|
||||
# node context. This runs a local action that gets and sets the necessary env variables that are needed
|
||||
- name: Set env variables
|
||||
uses: ./packages/cache/__tests__/__fixtures__/
|
||||
|
||||
# Need root node_modules because certain npm packages like jest are configured for the entire repository and it won't be possible
|
||||
# without these to just compile the cache package
|
||||
- name: Install root npm packages
|
||||
run: npm ci
|
||||
|
||||
- name: Compile cache package
|
||||
run: |
|
||||
npm ci
|
||||
npm run tsc
|
||||
working-directory: packages/cache
|
||||
|
||||
- name: Generate files in working directory
|
||||
shell: bash
|
||||
run: packages/cache/__tests__/create-cache-files.sh ${{ runner.os }} test-cache
|
||||
|
||||
- name: Generate files outside working directory
|
||||
shell: bash
|
||||
run: packages/cache/__tests__/create-cache-files.sh ${{ runner.os }} ~/test-cache
|
||||
|
||||
# We're using node -e to call the functions directly available in the @actions/cache package
|
||||
- name: Save cache using saveCache()
|
||||
run: |
|
||||
node -e "Promise.resolve(require('./packages/cache/lib/cache').saveCache(['test-cache','~/test-cache'],'test-${{ runner.os }}-${{ github.run_id }}'))"
|
||||
|
||||
- name: Delete cache folders before restoring
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf test-cache
|
||||
rm -rf ~/test-cache
|
||||
|
||||
- name: Restore cache using restoreCache() with http-client
|
||||
run: |
|
||||
node -e "Promise.resolve(require('./packages/cache/lib/cache').restoreCache(['test-cache','~/test-cache'],'test-${{ runner.os }}-${{ github.run_id }}',[],{useAzureSdk: false}))"
|
||||
|
||||
- name: Verify cache restored with http-client
|
||||
shell: bash
|
||||
run: |
|
||||
packages/cache/__tests__/verify-cache-files.sh ${{ runner.os }} test-cache
|
||||
packages/cache/__tests__/verify-cache-files.sh ${{ runner.os }} ~/test-cache
|
||||
|
||||
- name: Delete cache folders before restoring
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf test-cache
|
||||
rm -rf ~/test-cache
|
||||
|
||||
- name: Restore cache using restoreCache() with Azure SDK
|
||||
run: |
|
||||
node -e "Promise.resolve(require('./packages/cache/lib/cache').restoreCache(['test-cache','~/test-cache'],'test-${{ runner.os }}-${{ github.run_id }}'))"
|
||||
|
||||
- name: Verify cache restored with Azure SDK
|
||||
shell: bash
|
||||
run: |
|
||||
packages/cache/__tests__/verify-cache-files.sh ${{ runner.os }} test-cache
|
||||
packages/cache/__tests__/verify-cache-files.sh ${{ runner.os }} ~/test-cache
|
||||
@@ -2,6 +2,8 @@ name: "Code Scanning - Action"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0'
|
||||
@@ -18,7 +20,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Publish NPM
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
required: true
|
||||
description: 'core, artifact, cache, exec, github, glob, io, tool-cache'
|
||||
version:
|
||||
required: true
|
||||
description: 'the version of the package to publish'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Setup repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: npm install
|
||||
run: npm install
|
||||
|
||||
- name: bootstrap
|
||||
run: npm run bootstrap
|
||||
|
||||
- name: build
|
||||
run: npm run build
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: echo inputs
|
||||
run: echo ${{ github.event.inputs.package }} ${{ github.event.inputs.version }}
|
||||
|
||||
publish:
|
||||
runs-on: macos-latest
|
||||
needs: test
|
||||
environment: npm-publish
|
||||
steps:
|
||||
- name: Testing
|
||||
run: echo 'this is where we publish'
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
name: Publish NPM
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
required: true
|
||||
description: 'core, artifact, cache, exec, github, glob, http-client, io, tool-cache'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: setup repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: verify package exists
|
||||
run: ls packages/${{ github.event.inputs.package }}
|
||||
|
||||
- name: Set Node.js 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.x
|
||||
|
||||
- name: npm install
|
||||
run: npm install
|
||||
|
||||
- name: bootstrap
|
||||
run: npm run bootstrap
|
||||
|
||||
- name: build
|
||||
run: npm run build
|
||||
|
||||
- name: test
|
||||
run: npm run test
|
||||
|
||||
- name: pack
|
||||
run: npm pack
|
||||
working-directory: packages/${{ github.event.inputs.package }}
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ github.event.inputs.package }}
|
||||
path: packages/${{ github.event.inputs.package }}/*.tgz
|
||||
|
||||
publish:
|
||||
runs-on: macos-latest
|
||||
needs: test
|
||||
environment: npm-publish
|
||||
steps:
|
||||
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: ${{ github.event.inputs.package }}
|
||||
|
||||
- name: setup authentication
|
||||
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.TOKEN }}
|
||||
|
||||
- name: publish
|
||||
run: npm publish *.tgz
|
||||
|
||||
- name: notify slack on failure
|
||||
if: failure()
|
||||
run: |
|
||||
curl -X POST -H 'Content-type: application/json' --data '{"text":":pb__failed: Failed to publish a new version of ${{ github.event.inputs.package }}"}' $SLACK_WEBHOOK
|
||||
env:
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK }}
|
||||
|
||||
- name: notify slack on success
|
||||
if: success()
|
||||
run: |
|
||||
curl -X POST -H 'Content-type: application/json' --data '{"text":":dance: Successfully published a new version of ${{ github.event.inputs.package }}"}' $SLACK_WEBHOOK
|
||||
env:
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK }}
|
||||
|
||||
@@ -23,12 +23,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set Node.js 12.x
|
||||
uses: actions/setup-node@v1
|
||||
- name: Set Node.js 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 12.x
|
||||
node-version: 16.x
|
||||
|
||||
- name: npm install
|
||||
run: npm install
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
run: npm run build
|
||||
|
||||
- name: npm test
|
||||
run: npm test
|
||||
run: npm test -- --runInBand
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
name: "UpdateOctokit"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 18 * * 0' # sunday at 18 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
UpdateOctokit:
|
||||
@@ -10,7 +9,7 @@ jobs:
|
||||
if: ${{ github.repository_owner == 'actions' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
- name: Update Octokit
|
||||
working-directory: packages/github
|
||||
run: |
|
||||
@@ -31,7 +30,7 @@ jobs:
|
||||
fi
|
||||
- name: Create PR
|
||||
if: ${{steps.status.outputs.createPR}}
|
||||
uses: actions/github-script@v2
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
packages/*/node_modules/
|
||||
packages/*/lib/
|
||||
packages/*/lib/
|
||||
packages/glob/__tests__/_temp/**/
|
||||
+2
-1
@@ -7,5 +7,6 @@
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": false,
|
||||
"arrowParens": "avoid",
|
||||
"parser": "typescript"
|
||||
"parser": "typescript",
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
+2
-3
@@ -1,5 +1,4 @@
|
||||
* @actions/actions-runtime
|
||||
|
||||
/packages/artifact/ @actions/actions-service
|
||||
/packages/cache/ @actions/actions-service
|
||||
/packages/tool-cache/ @actions/spark
|
||||
/packages/artifact/ @actions/artifacts-actions
|
||||
/packages/cache/ @actions/actions-cache
|
||||
|
||||
@@ -46,6 +46,15 @@ $ npm install @actions/glob
|
||||
```
|
||||
<br/>
|
||||
|
||||
:phone: [@actions/http-client](packages/http-client)
|
||||
|
||||
A lightweight HTTP client optimized for building actions. Read more [here](packages/http-client)
|
||||
|
||||
```bash
|
||||
$ npm install @actions/http-client
|
||||
```
|
||||
<br/>
|
||||
|
||||
:pencil2: [@actions/io](packages/io)
|
||||
|
||||
Provides disk i/o functions like cp, mv, rmRF, which etc. Read more [here](packages/io)
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
os: [ubuntu-16.04, windows-2019]
|
||||
runs-on: ${{matrix.os}}
|
||||
actions:
|
||||
- uses: actions/setup-node@v1
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
version: ${{matrix.node}}
|
||||
- run: |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
+26
-5
@@ -50,14 +50,25 @@ function setSecret(secret: string): void {}
|
||||
|
||||
Now, future logs containing BAR will be masked. E.g. running `echo "Hello FOO BAR World"` will now print `Hello FOO **** World`.
|
||||
|
||||
**WARNING** The add-mask and setSecret commands only support single line secrets. To register a multiline secrets you must register each line individually otherwise it will not be masked.
|
||||
**WARNING** The add-mask and setSecret commands only support single-line
|
||||
secrets or multi-line secrets that have been escaped. `@actions/core`
|
||||
`setSecret` will escape the string you provide by default. When an escaped
|
||||
multi-line string is provided the whole string and each of its lines
|
||||
individually will be masked. For example you can mask `first\nsecond\r\nthird`
|
||||
using:
|
||||
|
||||
```sh
|
||||
echo "::add-mask::first%0Asecond%0D%0Athird"
|
||||
```
|
||||
|
||||
This will mask `first%0Asecond%0D%0Athird`, `first`, `second` and `third`.
|
||||
|
||||
**WARNING** Do **not** mask short values if you can avoid it, it could render your output unreadable (and future steps' output as well).
|
||||
For example, if you mask the letter `l`, running `echo "Hello FOO BAR World"` will now print `He*********o FOO BAR Wor****d`
|
||||
|
||||
### Group and Ungroup Log Lines
|
||||
|
||||
Emitting a group with a title will instruct the logs to create a collapsable region up to the next ungroup command.
|
||||
Emitting a group with a title will instruct the logs to create a collapsible region up to the next endgroup command.
|
||||
|
||||
```bash
|
||||
echo "::group::my title"
|
||||
@@ -72,6 +83,7 @@ function endGroup(): void {}
|
||||
```
|
||||
|
||||
### Problem Matchers
|
||||
|
||||
Problems matchers can be used to scan a build's output to automatically surface lines to the user that matches the provided pattern. A file path to a .json Problem Matcher must be provided. See [Problem Matchers](problem-matchers.md) for more information on how to define a Problem Matcher.
|
||||
|
||||
```bash
|
||||
@@ -81,6 +93,7 @@ echo "::remove-matcher owner=eslint-compact::"
|
||||
|
||||
`add-matcher` takes a path to a Problem Matcher file
|
||||
`remove-matcher` removes a Problem Matcher by owner
|
||||
|
||||
### Save State
|
||||
|
||||
Save a state to an environmental variable that can later be used in the main or post action.
|
||||
@@ -98,10 +111,14 @@ There are several commands to emit different levels of log output:
|
||||
| log level | example usage |
|
||||
|---|---|
|
||||
| [debug](action-debugging.md) | `echo "::debug::My debug message"` |
|
||||
| notice | `echo "::notice::My notice message"` |
|
||||
| warning | `echo "::warning::My warning message"` |
|
||||
| error | `echo "::error::My error message"` |
|
||||
|
||||
Additional syntax options are described at [the workflow command documentation](https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-a-debug-message).
|
||||
|
||||
### Command Echoing
|
||||
|
||||
By default, the echoing of commands to stdout only occurs if [Step Debugging is enabled](./action-debugging.md#How-to-Access-Step-Debug-Logs)
|
||||
|
||||
You can enable or disable this for the current step by using the `echo` command.
|
||||
@@ -127,12 +144,12 @@ The `add-mask`, `debug`, `warning` and `error` commands do not support echoing.
|
||||
### Command Prompt
|
||||
|
||||
CMD processes the `"` character differently from other shells when echoing. In CMD, the above snippets should have the `"` characters removed in order to correctly process. For example, the set output command would be:
|
||||
|
||||
```cmd
|
||||
echo ::set-output name=FOO::BAR
|
||||
```
|
||||
|
||||
|
||||
# Environment files
|
||||
## Environment files
|
||||
|
||||
During the execution of a workflow, the runner generates temporary files that can be used to perform certain actions. The path to these files are exposed via environment variables. You will need to use the `utf-8` encoding when writing to these files to ensure proper processing of the commands. Multiple commands can be written to the same file, separated by newlines.
|
||||
|
||||
@@ -146,7 +163,8 @@ echo "FOO=BAR" >> $GITHUB_ENV
|
||||
|
||||
Running `$FOO` in a future step will now return `BAR`
|
||||
|
||||
For multiline strings, you may use a heredoc style syntax with your choice of delimeter. In the below example, we use `EOF`
|
||||
For multiline strings, you may use a heredoc style syntax with your choice of delimeter. In the below example, we use `EOF`.
|
||||
|
||||
```
|
||||
steps:
|
||||
- name: Set the value
|
||||
@@ -160,6 +178,7 @@ steps:
|
||||
This would set the value of the `JSON_RESPONSE` env variable to the value of the curl response.
|
||||
|
||||
The expected syntax for the heredoc style is:
|
||||
|
||||
```
|
||||
{VARIABLE_NAME}<<{DELIMETER}
|
||||
{VARIABLE_VALUE}
|
||||
@@ -183,6 +202,7 @@ echo "/Users/test/.nvm/versions/node/v12.18.3/bin" >> $GITHUB_PATH
|
||||
Running `$PATH` in a future step will now return `/Users/test/.nvm/versions/node/v12.18.3/bin:{Previous Path}`;
|
||||
|
||||
This is wrapped by the core addPath method:
|
||||
|
||||
```javascript
|
||||
export function addPath(inputPath: string): void {}
|
||||
```
|
||||
@@ -190,6 +210,7 @@ export function addPath(inputPath: string): void {}
|
||||
### Powershell
|
||||
|
||||
Powershell does not use UTF8 by default. You will want to make sure you write in the correct encoding. For example, to set the path:
|
||||
|
||||
```
|
||||
steps:
|
||||
- run: echo "mypath" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
@@ -18,7 +18,7 @@ e.g. To use https://github.com/actions/setup-node, users will author:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
using: actions/setup-node@v1
|
||||
using: actions/setup-node@v3
|
||||
```
|
||||
|
||||
# Define Metadata
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
Problem Matchers are a way to scan the output of actions for a specified regex pattern and surface that information prominently in the UI. Both [GitHub Annotations](https://developer.github.com/v3/checks/runs/#annotations-object-1) and log file decorations are created when a match is detected.
|
||||
|
||||
## Limitations
|
||||
|
||||
Currently, GitHub Actions limit the annotation count in a workflow run.
|
||||
|
||||
- 10 warning annotations, 10 error annotations, and 10 notice annotations per step
|
||||
- 50 annotations per job (sum of annotations from all the steps)
|
||||
- 50 annotations per run (separate from the job annotations, these annotations aren’t created by users)
|
||||
|
||||
If your workflow may exceed these annotation counts, consider filtering of the log messages which the Problem Matcher is exposed to (e.g. by PR touched files, lines, or other).
|
||||
|
||||
## Single Line Matchers
|
||||
|
||||
Let's consider the ESLint compact output:
|
||||
@@ -100,6 +110,16 @@ The eslint-stylish problem matcher defined below catches that output, and create
|
||||
The first pattern matches the `test.js` line and records the file information. This line is not decorated in the UI.
|
||||
The second pattern loops through the remaining lines with `loop: true` until it fails to find a match, and surfaces these lines prominently in the UI.
|
||||
|
||||
Note that the pattern matches must be on consecutive lines. The following would not result in any match findings.
|
||||
|
||||
```
|
||||
test.js
|
||||
extraneous log line of no interest
|
||||
1:0 error Missing "use strict" statement strict
|
||||
5:10 error 'addOne' is defined but never used no-unused-vars
|
||||
✖ 2 problems (2 errors, 0 warnings)
|
||||
```
|
||||
|
||||
## Adding and Removing Problem Matchers
|
||||
|
||||
Problem Matchers are enabled and removed via the toolkit [commands](commands.md#problem-matchers).
|
||||
@@ -124,6 +144,6 @@ Use ECMAScript regular expression syntax when testing patterns.
|
||||
|
||||
### File property getting dropped
|
||||
|
||||
[Enable debug logging](https://help.github.com/en/actions/configuring-and-managing-workflows/managing-a-workflow-run#enabling-debug-logging) to determine why the file is getting dropped.
|
||||
[Enable debug logging](https://docs.github.com/en/actions/managing-workflow-runs/enabling-debug-logging) to determine why the file is getting dropped.
|
||||
|
||||
This usually happens when the file does not exist or is not under the workflow repo.
|
||||
|
||||
@@ -4,7 +4,7 @@ Self-hosted runners [can be configured](https://help.github.com/en/actions/hosti
|
||||
|
||||
For actions to **just work** behind a proxy server:
|
||||
|
||||
1. Use [tool-cache] version >= 1.3.1
|
||||
2. Optionally use [actions/http-client](https://github.com/actions/http-client)
|
||||
1. Use [tool-cache](/packages/tool-cache) version >= 1.3.1
|
||||
2. Optionally use [actions/http-client](/packages/http-client)
|
||||
|
||||
If you are using other http clients, refer to the [environment variables set by the runner](https://help.github.com/en/actions/hosting-your-own-runners/using-a-proxy-server-with-self-hosted-runners).
|
||||
If you are using other http clients, refer to the [environment variables set by the runner](https://help.github.com/en/actions/hosting-your-own-runners/using-a-proxy-server-with-self-hosted-runners).
|
||||
|
||||
@@ -4,7 +4,6 @@ module.exports = {
|
||||
roots: ['<rootDir>/packages'],
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/__tests__/*.test.ts'],
|
||||
testRunner: 'jest-circus/runner',
|
||||
transform: {
|
||||
'^.+\\.ts$': 'ts-jest'
|
||||
},
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"packages": [
|
||||
"packages/*"
|
||||
"packages/**/*"
|
||||
],
|
||||
"version": "independent"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"tasksRunnerOptions": {
|
||||
"default": {
|
||||
"runner": "nx/tasks-runners/default",
|
||||
"options": {
|
||||
"cacheableOperations": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"affected": {
|
||||
"defaultBase": "master"
|
||||
},
|
||||
"$schema": "./node_modules/nx/schemas/nx-schema.json",
|
||||
"namedInputs": {
|
||||
"default": [
|
||||
"{projectRoot}/**/*",
|
||||
"sharedGlobals"
|
||||
],
|
||||
"sharedGlobals": [],
|
||||
"production": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+11874
-18630
File diff suppressed because it is too large
Load Diff
+20
-16
@@ -3,30 +3,34 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"audit-all": "lerna run audit-moderate",
|
||||
"bootstrap": "lerna bootstrap",
|
||||
"bootstrap": "lerna exec -- npm install",
|
||||
"build": "lerna run tsc",
|
||||
"clean": "lerna clean",
|
||||
"repair": "lerna repair",
|
||||
"check-all": "concurrently \"npm:format-check\" \"npm:lint\" \"npm:test\" \"npm:build -- -- --noEmit\"",
|
||||
"format": "prettier --write packages/**/*.ts",
|
||||
"format-check": "prettier --check packages/**/*.ts",
|
||||
"lint": "eslint packages/**/*.ts",
|
||||
"lint-fix": "eslint packages/**/*.ts --fix",
|
||||
"new-package": "scripts/create-package",
|
||||
"test": "jest --testTimeout 10000"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^24.0.11",
|
||||
"@types/node": "^12.12.47",
|
||||
"@types/signale": "^1.2.1",
|
||||
"@typescript-eslint/parser": "^2.2.7",
|
||||
"concurrently": "^4.1.0",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-plugin-github": "^2.0.0",
|
||||
"eslint-plugin-jest": "^22.5.1",
|
||||
"@types/jest": "^27.0.2",
|
||||
"@types/node": "^16.18.1",
|
||||
"@types/signale": "^1.4.1",
|
||||
"concurrently": "^6.1.0",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-prettier": "^8.9.0",
|
||||
"eslint-plugin-github": "^4.9.2",
|
||||
"eslint-plugin-jest": "^27.2.3",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"flow-bin": "^0.115.0",
|
||||
"jest": "^25.1.0",
|
||||
"jest-circus": "^24.7.1",
|
||||
"lerna": "^3.18.4",
|
||||
"prettier": "^1.19.1",
|
||||
"ts-jest": "^25.4.0",
|
||||
"typescript": "^3.7.4"
|
||||
"jest": "^27.2.5",
|
||||
"lerna": "^7.1.4",
|
||||
"nx": "16.6.0",
|
||||
"prettier": "^3.0.0",
|
||||
"ts-jest": "^27.0.5",
|
||||
"typescript": "^3.9.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-205
@@ -2,212 +2,12 @@
|
||||
|
||||
## Usage
|
||||
|
||||
You can use this package to interact with the actions artifacts.
|
||||
- [Upload an Artifact](#Upload-an-Artifact)
|
||||
- [Download a Single Artifact](#Download-a-Single-Artifact)
|
||||
- [Download All Artifacts](#Download-all-Artifacts)
|
||||
- [Additional Documentation](#Additional-Documentation)
|
||||
- [Contributions](#Contributions)
|
||||
You can use this package to interact with the Actions artifacts.
|
||||
|
||||
Relative paths and absolute paths are both allowed. Relative paths are rooted against the current working directory.
|
||||
This most recently published version of this package (`1.1.1`) can be found [here](https://github.com/actions/toolkit/tree/@actions/artifact@1.1.1/packages/artifact)
|
||||
|
||||
## Upload an Artifact
|
||||
## 🚧 Under construction 🚧
|
||||
|
||||
Method Name: `uploadArtifact`
|
||||
This package is currently undergoing a major overhaul in preparation for `v4` versions of `upload-artifact` and `download-artifact` (these Actions will use a new `2.0.0` version of `@actions/artifact` that will soon be released). The upcoming version of `@actions/artifact` will take advantage of a major re-architecture with entirely new APIs.
|
||||
|
||||
#### Inputs
|
||||
- `name`
|
||||
- The name of the artifact that is being uploaded
|
||||
- Required
|
||||
- `files`
|
||||
- A list of file paths that describe what should be uploaded as part of the artifact
|
||||
- If a path is provided that does not exist, an error will be thrown
|
||||
- Can be absolute or relative. Internally everything is normalized and resolved
|
||||
- Required
|
||||
- `rootDirectory`
|
||||
- A file path that denotes the root directory of the files being uploaded. This path is used to strip the paths provided in `files` to control how they are uploaded and structured
|
||||
- If a file specified in `files` is not in the `rootDirectory`, an error will be thrown
|
||||
- Required
|
||||
- `options`
|
||||
- Extra options that allow for the customization of the upload behavior
|
||||
- Optional
|
||||
|
||||
#### Available Options
|
||||
|
||||
- `continueOnError`
|
||||
- Indicates if the artifact upload should continue in the event a file fails to upload. If there is a error during upload, a partial artifact will always be created and available for download at the end. The `size` reported will be the amount of storage that the user or org will be charged for the partial artifact.
|
||||
- If set to `false`, and an error is encountered, all other uploads will stop and any files that were queued will not be attempted to be uploaded. The partial artifact available will only include files up until the failure.
|
||||
- If set to `true` and an error is encountered, the failed file will be skipped and ignored and all other queued files will be attempted to be uploaded. There will be an artifact available for download at the end with everything excluding the file that failed to upload
|
||||
- Optional, defaults to `true` if not specified
|
||||
- `retentionDays`
|
||||
- Duration after which artifact will expire in days
|
||||
- Minimum value: 1
|
||||
- Maximum value: 90 unless changed by repository setting
|
||||
- If this is set to a greater value than the retention settings allowed, the retention on artifacts will be reduced to match the max value allowed on the server, and the upload process will continue. An input of 0 assumes default retention value.
|
||||
|
||||
#### Example using Absolute File Paths
|
||||
|
||||
```js
|
||||
const artifact = require('@actions/artifact');
|
||||
const artifactClient = artifact.create()
|
||||
const artifactName = 'my-artifact';
|
||||
const files = [
|
||||
'/home/user/files/plz-upload/file1.txt',
|
||||
'/home/user/files/plz-upload/file2.txt',
|
||||
'/home/user/files/plz-upload/dir/file3.txt'
|
||||
]
|
||||
const rootDirectory = '/home/user/files/plz-upload'
|
||||
const options = {
|
||||
continueOnError: true
|
||||
}
|
||||
|
||||
const uploadResult = await artifactClient.uploadArtifact(artifactName, files, rootDirectory, options)
|
||||
```
|
||||
|
||||
#### Example using Relative File Paths
|
||||
```js
|
||||
// Assuming the current working directory is /home/user/files/plz-upload
|
||||
const artifact = require('@actions/artifact');
|
||||
const artifactClient = artifact.create()
|
||||
const artifactName = 'my-artifact';
|
||||
const files = [
|
||||
'file1.txt',
|
||||
'file2.txt',
|
||||
'dir/file3.txt'
|
||||
]
|
||||
|
||||
const rootDirectory = '.' // Also possible to use __dirname
|
||||
const options = {
|
||||
continueOnError: false
|
||||
}
|
||||
|
||||
const uploadResponse = await artifactClient.uploadArtifact(artifactName, files, rootDirectory, options)
|
||||
```
|
||||
|
||||
#### Upload Result
|
||||
|
||||
The returned `UploadResponse` will contain the following information
|
||||
|
||||
- `artifactName`
|
||||
- The name of the artifact that was uploaded
|
||||
- `artifactItems`
|
||||
- A list of all files that describe what is uploaded if there are no errors encountered. Usually this will be equal to the inputted `files` with the exception of empty directories (will not be uploaded)
|
||||
- `size`
|
||||
- Total size of the artifact that was uploaded in bytes
|
||||
- `failedItems`
|
||||
- A list of items that were not uploaded successfully (this will include queued items that were not uploaded if `continueOnError` is set to false). This is a subset of `artifactItems`
|
||||
|
||||
## Download a Single Artifact
|
||||
|
||||
Method Name: `downloadArtifact`
|
||||
|
||||
#### Inputs
|
||||
- `name`
|
||||
- The name of the artifact to download
|
||||
- Required
|
||||
- `path`
|
||||
- Path that denotes where the artifact will be downloaded to
|
||||
- Optional. Defaults to the GitHub workspace directory(`$GITHUB_WORKSPACE`) if not specified
|
||||
- `options`
|
||||
- Extra options that allow for the customization of the download behavior
|
||||
- Optional
|
||||
|
||||
|
||||
#### Available Options
|
||||
|
||||
- `createArtifactFolder`
|
||||
- Specifies if a folder (the artifact name) is created for the artifact that is downloaded (contents downloaded into this folder),
|
||||
- Optional. Defaults to false if not specified
|
||||
|
||||
#### Example
|
||||
|
||||
```js
|
||||
const artifact = require('@actions/artifact');
|
||||
const artifactClient = artifact.create()
|
||||
const artifactName = 'my-artifact';
|
||||
const path = 'some/directory'
|
||||
const options = {
|
||||
createArtifactFolder: false
|
||||
}
|
||||
|
||||
const downloadResponse = await artifactClient.downloadArtifact(artifactName, path, options)
|
||||
|
||||
// Post download, the directory structure will look like this
|
||||
/some
|
||||
/directory
|
||||
/file1.txt
|
||||
/file2.txt
|
||||
/dir
|
||||
/file3.txt
|
||||
|
||||
// If createArtifactFolder is set to true, the directory structure will look like this
|
||||
/some
|
||||
/directory
|
||||
/my-artifact
|
||||
/file1.txt
|
||||
/file2.txt
|
||||
/dir
|
||||
/file3.txt
|
||||
```
|
||||
|
||||
#### Download Response
|
||||
|
||||
The returned `DownloadResponse` will contain the following information
|
||||
|
||||
- `artifactName`
|
||||
- The name of the artifact that was downloaded
|
||||
- `downloadPath`
|
||||
- The full Path to where the artifact was downloaded
|
||||
|
||||
|
||||
## Download All Artifacts
|
||||
|
||||
Method Name: `downloadAllArtifacts`
|
||||
|
||||
#### Inputs
|
||||
- `path`
|
||||
- Path that denotes where the artifact will be downloaded to
|
||||
- Optional. Defaults to the GitHub workspace directory(`$GITHUB_WORKSPACE`) if not specified
|
||||
|
||||
```js
|
||||
const artifact = require('@actions/artifact');
|
||||
const artifactClient = artifact.create();
|
||||
const downloadResponse = await artifactClient.downloadAllArtifacts();
|
||||
|
||||
// output result
|
||||
for (response in downloadResponse) {
|
||||
console.log(response.artifactName);
|
||||
console.log(response.downloadPath);
|
||||
}
|
||||
```
|
||||
|
||||
Because there are multiple artifacts, an extra directory (denoted by the name of the artifact) will be created for each artifact in the path. With 2 artifacts(`my-artifact-1` and `my-artifact-2` for example) and the default path, the directory structure will be as follows:
|
||||
```js
|
||||
/GITHUB_WORKSPACE
|
||||
/my-artifact-1
|
||||
/ .. contents of `my-artifact-1`
|
||||
/my-artifact-2
|
||||
/ .. contents of `my-artifact-2`
|
||||
```
|
||||
|
||||
#### Download Result
|
||||
|
||||
An array will be returned that describes the results for downloading all artifacts. The number of items in the array indicates the number of artifacts that were downloaded.
|
||||
|
||||
Each artifact will have the same `DownloadResponse` as if it was individually downloaded
|
||||
- `artifactName`
|
||||
- The name of the artifact that was downloaded
|
||||
- `downloadPath`
|
||||
- The full Path to where the artifact was downloaded
|
||||
|
||||
## Additional Documentation
|
||||
|
||||
Check out [additional-information](docs/additional-information.md) for extra documentation around usage, restrictions and behavior.
|
||||
|
||||
Check out [implementation-details](docs/implementation-details.md) for extra information about the implementation of this package.
|
||||
|
||||
## Contributions
|
||||
|
||||
See [contributor guidelines](https://github.com/actions/toolkit/blob/main/.github/CONTRIBUTING.md) for general guidelines and information about toolkit contributions.
|
||||
|
||||
For contributions related to this package, see [artifact contributions](CONTRIBUTIONS.md) for more information.
|
||||
The upcoming `2.0.0` package and `v4` artifact Actions aim to solve some of the major pain-points that have made artifact usage difficult up until now.
|
||||
|
||||
@@ -54,3 +54,43 @@
|
||||
|
||||
- Improved retry-ability for all http calls during artifact upload and download if an error is encountered
|
||||
|
||||
### 0.5.1
|
||||
|
||||
- Bump @actions/http-client to version 1.0.11 to fix proxy related issues during artifact upload and download
|
||||
|
||||
### 0.5.2
|
||||
|
||||
- Add HTTP 500 as a retryable status code for artifact upload and download.
|
||||
|
||||
### 0.6.0
|
||||
|
||||
- Support upload from named pipes [#748](https://github.com/actions/toolkit/pull/748)
|
||||
- Fixes to percentage values being greater than 100% when downloading all artifacts [#889](https://github.com/actions/toolkit/pull/889)
|
||||
- Improved logging and output during artifact upload [#949](https://github.com/actions/toolkit/pull/949)
|
||||
- Improvements to client-side validation for certain invalid characters not allowed during upload: [#951](https://github.com/actions/toolkit/pull/951)
|
||||
- Faster upload speeds for certain types of large files by exempting gzip compression [#956](https://github.com/actions/toolkit/pull/956)
|
||||
- More detailed logging when dealing with chunked uploads [#957](https://github.com/actions/toolkit/pull/957)
|
||||
|
||||
### 0.6.1
|
||||
|
||||
- Fix for failing 0 byte file uploads on Windows [#962](https://github.com/actions/toolkit/pull/962)
|
||||
|
||||
### 1.0.0
|
||||
|
||||
- Update `lockfileVersion` to `v2` in `package-lock.json` [#1009](https://github.com/actions/toolkit/pull/1009)
|
||||
|
||||
### 1.0.1
|
||||
|
||||
- Update to v2.0.0 of `@actions/http-client`
|
||||
|
||||
### 1.0.2
|
||||
|
||||
- Update to v2.0.1 of `@actions/http-client` [#1087](https://github.com/actions/toolkit/pull/1087)
|
||||
|
||||
### 1.1.0
|
||||
|
||||
- Add `x-actions-results-crc64` and `x-actions-results-md5` checksum headers on upload [#1063](https://github.com/actions/toolkit/pull/1063)
|
||||
|
||||
### 1.1.1
|
||||
|
||||
- Fixed a bug in Node16 where if an HTTP download finished too quickly (<1ms, e.g. when it's mocked) we attempt to delete a temp file that has not been created yet [#1278](https://github.com/actions/toolkit/pull/1278/commits/b9de68a590daf37c6747e38d3cb4f1dd2cfb791c)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
name: 'Set env variables'
|
||||
description: 'Sets certain env variables so that e2e artifact upload and download can be tested in a shell'
|
||||
runs:
|
||||
using: 'node12'
|
||||
main: 'index.js'
|
||||
@@ -1,14 +0,0 @@
|
||||
// Certain env variables are not set by default in a shell context and are only available in a node context from a running action
|
||||
// In order to be able to upload and download artifacts e2e in a shell when running CI tests, we need these env variables set
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const filePath = process.env[`GITHUB_ENV`]
|
||||
fs.appendFileSync(filePath, `ACTIONS_RUNTIME_URL=${process.env.ACTIONS_RUNTIME_URL}${os.EOL}`, {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
fs.appendFileSync(filePath, `ACTIONS_RUNTIME_TOKEN=${process.env.ACTIONS_RUNTIME_TOKEN}${os.EOL}`, {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
fs.appendFileSync(filePath, `GITHUB_RUN_ID=${process.env.GITHUB_RUN_ID}${os.EOL}`, {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
@@ -1,552 +0,0 @@
|
||||
import * as path from 'path'
|
||||
import * as core from '@actions/core'
|
||||
import {URL} from 'url'
|
||||
import {getDownloadSpecification} from '../src/internal/download-specification'
|
||||
import {ContainerEntry} from '../src/internal/contracts'
|
||||
|
||||
const artifact1Name = 'my-artifact'
|
||||
const artifact2Name = 'my-artifact-extra'
|
||||
|
||||
// Populating with only the information that is necessary
|
||||
function getPartialContainerEntry(): ContainerEntry {
|
||||
return {
|
||||
containerId: 10,
|
||||
scopeIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
path: 'ADD_INFORMATION',
|
||||
itemType: 'ADD_INFORMATION',
|
||||
status: 'created',
|
||||
dateCreated: '2020-02-06T22:13:35.373Z',
|
||||
dateLastModified: '2020-02-06T22:13:35.453Z',
|
||||
createdBy: '82f0bf89-6e55-4e5a-b8b6-f75eb992578c',
|
||||
lastModifiedBy: '82f0bf89-6e55-4e5a-b8b6-f75eb992578c',
|
||||
itemLocation: 'ADD_INFORMATION',
|
||||
contentLocation: 'ADD_INFORMATION',
|
||||
contentId: '',
|
||||
fileLength: 100
|
||||
}
|
||||
}
|
||||
|
||||
function createFileEntry(entryPath: string): ContainerEntry {
|
||||
const newFileEntry = getPartialContainerEntry()
|
||||
newFileEntry.path = entryPath
|
||||
newFileEntry.itemType = 'file'
|
||||
newFileEntry.itemLocation = createItemLocation(entryPath)
|
||||
newFileEntry.contentLocation = createContentLocation(entryPath)
|
||||
return newFileEntry
|
||||
}
|
||||
|
||||
function createDirectoryEntry(directoryPath: string): ContainerEntry {
|
||||
const newDirectoryEntry = getPartialContainerEntry()
|
||||
newDirectoryEntry.path = directoryPath
|
||||
newDirectoryEntry.itemType = 'folder'
|
||||
newDirectoryEntry.itemLocation = createItemLocation(directoryPath)
|
||||
newDirectoryEntry.contentLocation = createContentLocation(directoryPath)
|
||||
return newDirectoryEntry
|
||||
}
|
||||
|
||||
function createItemLocation(relativePath: string): string {
|
||||
const itemLocation = new URL(
|
||||
'https://testing/_apis/resources/Containers/10000'
|
||||
)
|
||||
itemLocation.searchParams.append('itemPath', relativePath)
|
||||
itemLocation.searchParams.append('metadata', 'true')
|
||||
return itemLocation.toString()
|
||||
}
|
||||
|
||||
function createContentLocation(relativePath: string): string {
|
||||
const itemLocation = new URL(
|
||||
'https://testing/_apis/resources/Containers/10000'
|
||||
)
|
||||
itemLocation.searchParams.append('itemPath', relativePath)
|
||||
return itemLocation.toString()
|
||||
}
|
||||
|
||||
/*
|
||||
Represents a set of container entries for two artifacts with the following directory structure
|
||||
|
||||
/my-artifact
|
||||
/file1.txt
|
||||
/file2.txt
|
||||
/dir1
|
||||
/file3.txt
|
||||
/dir2
|
||||
/dir3
|
||||
/dir4
|
||||
file4.txt
|
||||
file5.txt (no length property)
|
||||
file6.txt (empty file)
|
||||
/my-artifact-extra
|
||||
/file1.txt
|
||||
*/
|
||||
|
||||
// main artifact
|
||||
const file1Path = path.join(artifact1Name, 'file1.txt')
|
||||
const file2Path = path.join(artifact1Name, 'file2.txt')
|
||||
const dir1Path = path.join(artifact1Name, 'dir1')
|
||||
const file3Path = path.join(dir1Path, 'file3.txt')
|
||||
const dir2Path = path.join(dir1Path, 'dir2')
|
||||
const dir3Path = path.join(dir2Path, 'dir3')
|
||||
const dir4Path = path.join(dir3Path, 'dir4')
|
||||
const file4Path = path.join(dir4Path, 'file4.txt')
|
||||
const file5Path = path.join(dir4Path, 'file5.txt')
|
||||
const file6Path = path.join(dir4Path, 'file6.txt')
|
||||
|
||||
const rootDirectoryEntry = createDirectoryEntry(artifact1Name)
|
||||
const directoryEntry1 = createDirectoryEntry(dir1Path)
|
||||
const directoryEntry2 = createDirectoryEntry(dir2Path)
|
||||
const directoryEntry3 = createDirectoryEntry(dir3Path)
|
||||
const directoryEntry4 = createDirectoryEntry(dir4Path)
|
||||
const fileEntry1 = createFileEntry(file1Path)
|
||||
const fileEntry2 = createFileEntry(file2Path)
|
||||
const fileEntry3 = createFileEntry(file3Path)
|
||||
const fileEntry4 = createFileEntry(file4Path)
|
||||
|
||||
const missingLengthFileEntry = createFileEntry(file5Path)
|
||||
missingLengthFileEntry.fileLength = undefined // one file does not have a fileLength
|
||||
const emptyLengthFileEntry = createFileEntry(file6Path)
|
||||
emptyLengthFileEntry.fileLength = 0 // empty file path
|
||||
|
||||
// extra artifact
|
||||
const artifact2File1Path = path.join(artifact2Name, 'file1.txt')
|
||||
const rootDirectoryEntry2 = createDirectoryEntry(artifact2Name)
|
||||
const extraFileEntry = createFileEntry(artifact2File1Path)
|
||||
|
||||
const artifactContainerEntries: ContainerEntry[] = [
|
||||
rootDirectoryEntry,
|
||||
fileEntry1,
|
||||
fileEntry2,
|
||||
directoryEntry1,
|
||||
fileEntry3,
|
||||
directoryEntry2,
|
||||
directoryEntry3,
|
||||
directoryEntry4,
|
||||
fileEntry4,
|
||||
missingLengthFileEntry,
|
||||
emptyLengthFileEntry,
|
||||
rootDirectoryEntry2,
|
||||
extraFileEntry
|
||||
]
|
||||
|
||||
describe('Search', () => {
|
||||
beforeAll(async () => {
|
||||
// mock all output so that there is less noise when running tests
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'debug').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'info').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'warning').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('Download Specification - Absolute Path with no root directory', () => {
|
||||
const testDownloadPath = path.join(
|
||||
__dirname,
|
||||
'some',
|
||||
'destination',
|
||||
'folder'
|
||||
)
|
||||
|
||||
const specification = getDownloadSpecification(
|
||||
artifact1Name,
|
||||
artifactContainerEntries,
|
||||
testDownloadPath,
|
||||
false
|
||||
)
|
||||
|
||||
expect(specification.rootDownloadLocation).toEqual(testDownloadPath)
|
||||
expect(specification.filesToDownload.length).toEqual(5)
|
||||
|
||||
const item1ExpectedTargetPath = path.join(testDownloadPath, 'file1.txt')
|
||||
const item2ExpectedTargetPath = path.join(testDownloadPath, 'file2.txt')
|
||||
const item3ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
'dir1',
|
||||
'file3.txt'
|
||||
)
|
||||
const item4ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
'dir1',
|
||||
'dir2',
|
||||
'dir3',
|
||||
'dir4',
|
||||
'file4.txt'
|
||||
)
|
||||
const item5ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
'dir1',
|
||||
'dir2',
|
||||
'dir3',
|
||||
'dir4',
|
||||
'file5.txt'
|
||||
)
|
||||
const item6ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
'dir1',
|
||||
'dir2',
|
||||
'dir3',
|
||||
'dir4',
|
||||
'file6.txt'
|
||||
)
|
||||
|
||||
const targetLocations = specification.filesToDownload.map(
|
||||
item => item.targetPath
|
||||
)
|
||||
expect(targetLocations).toContain(item1ExpectedTargetPath)
|
||||
expect(targetLocations).toContain(item2ExpectedTargetPath)
|
||||
expect(targetLocations).toContain(item3ExpectedTargetPath)
|
||||
expect(targetLocations).toContain(item4ExpectedTargetPath)
|
||||
expect(targetLocations).toContain(item5ExpectedTargetPath)
|
||||
|
||||
for (const downloadItem of specification.filesToDownload) {
|
||||
if (downloadItem.targetPath === item1ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file1Path)
|
||||
)
|
||||
} else if (downloadItem.targetPath === item2ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file2Path)
|
||||
)
|
||||
} else if (downloadItem.targetPath === item3ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file3Path)
|
||||
)
|
||||
} else if (downloadItem.targetPath === item4ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file4Path)
|
||||
)
|
||||
} else if (downloadItem.targetPath === item5ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file5Path)
|
||||
)
|
||||
} else {
|
||||
throw new Error('this should never be reached')
|
||||
}
|
||||
}
|
||||
|
||||
expect(specification.directoryStructure.length).toEqual(3)
|
||||
expect(specification.directoryStructure).toContain(testDownloadPath)
|
||||
expect(specification.directoryStructure).toContain(
|
||||
path.join(testDownloadPath, 'dir1')
|
||||
)
|
||||
expect(specification.directoryStructure).toContain(
|
||||
path.join(testDownloadPath, 'dir1', 'dir2', 'dir3', 'dir4')
|
||||
)
|
||||
|
||||
expect(specification.emptyFilesToCreate.length).toEqual(1)
|
||||
expect(specification.emptyFilesToCreate).toContain(item6ExpectedTargetPath)
|
||||
})
|
||||
|
||||
it('Download Specification - Relative Path with no root directory', () => {
|
||||
const testDownloadPath = path.join('some', 'destination', 'folder')
|
||||
|
||||
const specification = getDownloadSpecification(
|
||||
artifact1Name,
|
||||
artifactContainerEntries,
|
||||
testDownloadPath,
|
||||
false
|
||||
)
|
||||
|
||||
expect(specification.rootDownloadLocation).toEqual(testDownloadPath)
|
||||
expect(specification.filesToDownload.length).toEqual(5)
|
||||
|
||||
const item1ExpectedTargetPath = path.join(testDownloadPath, 'file1.txt')
|
||||
const item2ExpectedTargetPath = path.join(testDownloadPath, 'file2.txt')
|
||||
const item3ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
'dir1',
|
||||
'file3.txt'
|
||||
)
|
||||
const item4ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
'dir1',
|
||||
'dir2',
|
||||
'dir3',
|
||||
'dir4',
|
||||
'file4.txt'
|
||||
)
|
||||
const item5ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
'dir1',
|
||||
'dir2',
|
||||
'dir3',
|
||||
'dir4',
|
||||
'file5.txt'
|
||||
)
|
||||
const item6ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
'dir1',
|
||||
'dir2',
|
||||
'dir3',
|
||||
'dir4',
|
||||
'file6.txt'
|
||||
)
|
||||
|
||||
const targetLocations = specification.filesToDownload.map(
|
||||
item => item.targetPath
|
||||
)
|
||||
expect(targetLocations).toContain(item1ExpectedTargetPath)
|
||||
expect(targetLocations).toContain(item2ExpectedTargetPath)
|
||||
expect(targetLocations).toContain(item3ExpectedTargetPath)
|
||||
expect(targetLocations).toContain(item4ExpectedTargetPath)
|
||||
expect(targetLocations).toContain(item5ExpectedTargetPath)
|
||||
|
||||
for (const downloadItem of specification.filesToDownload) {
|
||||
if (downloadItem.targetPath === item1ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file1Path)
|
||||
)
|
||||
} else if (downloadItem.targetPath === item2ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file2Path)
|
||||
)
|
||||
} else if (downloadItem.targetPath === item3ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file3Path)
|
||||
)
|
||||
} else if (downloadItem.targetPath === item4ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file4Path)
|
||||
)
|
||||
} else if (downloadItem.targetPath === item5ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file5Path)
|
||||
)
|
||||
} else {
|
||||
throw new Error('this should never be reached')
|
||||
}
|
||||
}
|
||||
|
||||
expect(specification.directoryStructure.length).toEqual(3)
|
||||
expect(specification.directoryStructure).toContain(testDownloadPath)
|
||||
expect(specification.directoryStructure).toContain(
|
||||
path.join(testDownloadPath, 'dir1')
|
||||
)
|
||||
expect(specification.directoryStructure).toContain(
|
||||
path.join(testDownloadPath, 'dir1', 'dir2', 'dir3', 'dir4')
|
||||
)
|
||||
|
||||
expect(specification.emptyFilesToCreate.length).toEqual(1)
|
||||
expect(specification.emptyFilesToCreate).toContain(item6ExpectedTargetPath)
|
||||
})
|
||||
|
||||
it('Download Specification - Absolute Path with root directory', () => {
|
||||
const testDownloadPath = path.join(
|
||||
__dirname,
|
||||
'some',
|
||||
'destination',
|
||||
'folder'
|
||||
)
|
||||
|
||||
const specification = getDownloadSpecification(
|
||||
artifact1Name,
|
||||
artifactContainerEntries,
|
||||
testDownloadPath,
|
||||
true
|
||||
)
|
||||
|
||||
expect(specification.rootDownloadLocation).toEqual(
|
||||
path.join(testDownloadPath, artifact1Name)
|
||||
)
|
||||
expect(specification.filesToDownload.length).toEqual(5)
|
||||
|
||||
const item1ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
artifact1Name,
|
||||
'file1.txt'
|
||||
)
|
||||
const item2ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
artifact1Name,
|
||||
'file2.txt'
|
||||
)
|
||||
const item3ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
artifact1Name,
|
||||
'dir1',
|
||||
'file3.txt'
|
||||
)
|
||||
const item4ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
artifact1Name,
|
||||
'dir1',
|
||||
'dir2',
|
||||
'dir3',
|
||||
'dir4',
|
||||
'file4.txt'
|
||||
)
|
||||
const item5ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
artifact1Name,
|
||||
'dir1',
|
||||
'dir2',
|
||||
'dir3',
|
||||
'dir4',
|
||||
'file5.txt'
|
||||
)
|
||||
const item6ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
artifact1Name,
|
||||
'dir1',
|
||||
'dir2',
|
||||
'dir3',
|
||||
'dir4',
|
||||
'file6.txt'
|
||||
)
|
||||
|
||||
const targetLocations = specification.filesToDownload.map(
|
||||
item => item.targetPath
|
||||
)
|
||||
expect(targetLocations).toContain(item1ExpectedTargetPath)
|
||||
expect(targetLocations).toContain(item2ExpectedTargetPath)
|
||||
expect(targetLocations).toContain(item3ExpectedTargetPath)
|
||||
expect(targetLocations).toContain(item4ExpectedTargetPath)
|
||||
expect(targetLocations).toContain(item5ExpectedTargetPath)
|
||||
|
||||
for (const downloadItem of specification.filesToDownload) {
|
||||
if (downloadItem.targetPath === item1ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file1Path)
|
||||
)
|
||||
} else if (downloadItem.targetPath === item2ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file2Path)
|
||||
)
|
||||
} else if (downloadItem.targetPath === item3ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file3Path)
|
||||
)
|
||||
} else if (downloadItem.targetPath === item4ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file4Path)
|
||||
)
|
||||
} else if (downloadItem.targetPath === item5ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file5Path)
|
||||
)
|
||||
} else {
|
||||
throw new Error('this should never be reached')
|
||||
}
|
||||
}
|
||||
|
||||
expect(specification.directoryStructure.length).toEqual(3)
|
||||
expect(specification.directoryStructure).toContain(
|
||||
path.join(testDownloadPath, artifact1Name)
|
||||
)
|
||||
expect(specification.directoryStructure).toContain(
|
||||
path.join(testDownloadPath, dir1Path)
|
||||
)
|
||||
expect(specification.directoryStructure).toContain(
|
||||
path.join(testDownloadPath, dir4Path)
|
||||
)
|
||||
|
||||
expect(specification.emptyFilesToCreate.length).toEqual(1)
|
||||
expect(specification.emptyFilesToCreate).toContain(item6ExpectedTargetPath)
|
||||
})
|
||||
|
||||
it('Download Specification - Relative Path with root directory', () => {
|
||||
const testDownloadPath = path.join('some', 'destination', 'folder')
|
||||
|
||||
const specification = getDownloadSpecification(
|
||||
artifact1Name,
|
||||
artifactContainerEntries,
|
||||
testDownloadPath,
|
||||
true
|
||||
)
|
||||
|
||||
expect(specification.rootDownloadLocation).toEqual(
|
||||
path.join(testDownloadPath, artifact1Name)
|
||||
)
|
||||
expect(specification.filesToDownload.length).toEqual(5)
|
||||
|
||||
const item1ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
artifact1Name,
|
||||
'file1.txt'
|
||||
)
|
||||
const item2ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
artifact1Name,
|
||||
'file2.txt'
|
||||
)
|
||||
const item3ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
artifact1Name,
|
||||
'dir1',
|
||||
'file3.txt'
|
||||
)
|
||||
const item4ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
artifact1Name,
|
||||
'dir1',
|
||||
'dir2',
|
||||
'dir3',
|
||||
'dir4',
|
||||
'file4.txt'
|
||||
)
|
||||
const item5ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
artifact1Name,
|
||||
'dir1',
|
||||
'dir2',
|
||||
'dir3',
|
||||
'dir4',
|
||||
'file5.txt'
|
||||
)
|
||||
const item6ExpectedTargetPath = path.join(
|
||||
testDownloadPath,
|
||||
artifact1Name,
|
||||
'dir1',
|
||||
'dir2',
|
||||
'dir3',
|
||||
'dir4',
|
||||
'file6.txt'
|
||||
)
|
||||
|
||||
const targetLocations = specification.filesToDownload.map(
|
||||
item => item.targetPath
|
||||
)
|
||||
expect(targetLocations).toContain(item1ExpectedTargetPath)
|
||||
expect(targetLocations).toContain(item2ExpectedTargetPath)
|
||||
expect(targetLocations).toContain(item3ExpectedTargetPath)
|
||||
expect(targetLocations).toContain(item4ExpectedTargetPath)
|
||||
expect(targetLocations).toContain(item5ExpectedTargetPath)
|
||||
|
||||
for (const downloadItem of specification.filesToDownload) {
|
||||
if (downloadItem.targetPath === item1ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file1Path)
|
||||
)
|
||||
} else if (downloadItem.targetPath === item2ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file2Path)
|
||||
)
|
||||
} else if (downloadItem.targetPath === item3ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file3Path)
|
||||
)
|
||||
} else if (downloadItem.targetPath === item4ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file4Path)
|
||||
)
|
||||
} else if (downloadItem.targetPath === item5ExpectedTargetPath) {
|
||||
expect(downloadItem.sourceLocation).toEqual(
|
||||
createContentLocation(file5Path)
|
||||
)
|
||||
} else {
|
||||
throw new Error('this should never be reached')
|
||||
}
|
||||
}
|
||||
|
||||
expect(specification.directoryStructure.length).toEqual(3)
|
||||
expect(specification.directoryStructure).toContain(
|
||||
path.join(testDownloadPath, artifact1Name)
|
||||
)
|
||||
expect(specification.directoryStructure).toContain(
|
||||
path.join(testDownloadPath, dir1Path)
|
||||
)
|
||||
expect(specification.directoryStructure).toContain(
|
||||
path.join(testDownloadPath, dir4Path)
|
||||
)
|
||||
|
||||
expect(specification.emptyFilesToCreate.length).toEqual(1)
|
||||
expect(specification.emptyFilesToCreate).toContain(item6ExpectedTargetPath)
|
||||
})
|
||||
})
|
||||
@@ -1,490 +0,0 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as http from 'http'
|
||||
import * as io from '../../io/src/io'
|
||||
import * as net from 'net'
|
||||
import * as path from 'path'
|
||||
import * as configVariables from '../src/internal/config-variables'
|
||||
import {promises as fs} from 'fs'
|
||||
import {DownloadItem} from '../src/internal/download-specification'
|
||||
import {HttpClient, HttpClientResponse} from '@actions/http-client'
|
||||
import {DownloadHttpClient} from '../src/internal/download-http-client'
|
||||
import {
|
||||
ListArtifactsResponse,
|
||||
QueryArtifactResponse
|
||||
} from '../src/internal/contracts'
|
||||
import * as stream from 'stream'
|
||||
import {gzip} from 'zlib'
|
||||
import {promisify} from 'util'
|
||||
|
||||
const root = path.join(__dirname, '_temp', 'artifact-download-tests')
|
||||
const defaultEncoding = 'utf8'
|
||||
|
||||
jest.mock('../src/internal/config-variables')
|
||||
jest.mock('@actions/http-client')
|
||||
|
||||
describe('Download Tests', () => {
|
||||
beforeAll(async () => {
|
||||
await io.rmRF(root)
|
||||
await fs.mkdir(path.join(root), {
|
||||
recursive: true
|
||||
})
|
||||
|
||||
// mock all output so that there is less noise when running tests
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'debug').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'info').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'warning').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test Listing Artifacts
|
||||
*/
|
||||
it('List Artifacts - Success', async () => {
|
||||
setupSuccessfulListArtifactsResponse()
|
||||
const downloadHttpClient = new DownloadHttpClient()
|
||||
const artifacts = await downloadHttpClient.listArtifacts()
|
||||
expect(artifacts.count).toEqual(2)
|
||||
|
||||
const artifactNames = artifacts.value.map(item => item.name)
|
||||
expect(artifactNames).toContain('artifact1-name')
|
||||
expect(artifactNames).toContain('artifact2-name')
|
||||
|
||||
for (const artifact of artifacts.value) {
|
||||
if (artifact.name === 'artifact1-name') {
|
||||
expect(artifact.url).toEqual(
|
||||
`${configVariables.getRuntimeUrl()}_apis/pipelines/1/runs/1/artifacts?artifactName=artifact1-name`
|
||||
)
|
||||
} else if (artifact.name === 'artifact2-name') {
|
||||
expect(artifact.url).toEqual(
|
||||
`${configVariables.getRuntimeUrl()}_apis/pipelines/1/runs/1/artifacts?artifactName=artifact2-name`
|
||||
)
|
||||
} else {
|
||||
throw new Error(
|
||||
'Invalid artifact combination. This should never be reached'
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('List Artifacts - Failure', async () => {
|
||||
setupFailedResponse()
|
||||
const downloadHttpClient = new DownloadHttpClient()
|
||||
expect(downloadHttpClient.listArtifacts()).rejects.toThrow(
|
||||
'List Artifacts failed: Artifact service responded with 500'
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test Container Items
|
||||
*/
|
||||
it('Container Items - Success', async () => {
|
||||
setupSuccessfulContainerItemsResponse()
|
||||
const downloadHttpClient = new DownloadHttpClient()
|
||||
const response = await downloadHttpClient.getContainerItems(
|
||||
'artifact-name',
|
||||
configVariables.getRuntimeUrl()
|
||||
)
|
||||
expect(response.count).toEqual(2)
|
||||
|
||||
const itemPaths = response.value.map(item => item.path)
|
||||
expect(itemPaths).toContain('artifact-name')
|
||||
expect(itemPaths).toContain('artifact-name/file1.txt')
|
||||
|
||||
for (const containerEntry of response.value) {
|
||||
if (containerEntry.path === 'artifact-name') {
|
||||
expect(containerEntry.itemType).toEqual('folder')
|
||||
} else if (containerEntry.path === 'artifact-name/file1.txt') {
|
||||
expect(containerEntry.itemType).toEqual('file')
|
||||
} else {
|
||||
throw new Error(
|
||||
'Invalid container combination. This should never be reached'
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('Container Items - Failure', async () => {
|
||||
setupFailedResponse()
|
||||
const downloadHttpClient = new DownloadHttpClient()
|
||||
expect(
|
||||
downloadHttpClient.getContainerItems(
|
||||
'artifact-name',
|
||||
configVariables.getRuntimeUrl()
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`Get Container Items failed: Artifact service responded with 500`
|
||||
)
|
||||
})
|
||||
|
||||
it('Test downloading an individual artifact with gzip', async () => {
|
||||
const fileContents = Buffer.from(
|
||||
'gzip worked on the first try\n',
|
||||
defaultEncoding
|
||||
)
|
||||
const targetPath = path.join(root, 'FileA.txt')
|
||||
|
||||
setupDownloadItemResponse(fileContents, true, 200, false, false)
|
||||
const downloadHttpClient = new DownloadHttpClient()
|
||||
|
||||
const items: DownloadItem[] = []
|
||||
items.push({
|
||||
sourceLocation: `${configVariables.getRuntimeUrl()}_apis/resources/Containers/13?itemPath=my-artifact%2FFileA.txt`,
|
||||
targetPath
|
||||
})
|
||||
|
||||
await expect(
|
||||
downloadHttpClient.downloadSingleArtifact(items)
|
||||
).resolves.not.toThrow()
|
||||
|
||||
await checkDestinationFile(targetPath, fileContents)
|
||||
})
|
||||
|
||||
it('Test downloading an individual artifact without gzip', async () => {
|
||||
const fileContents = Buffer.from(
|
||||
'plaintext worked on the first try\n',
|
||||
defaultEncoding
|
||||
)
|
||||
const targetPath = path.join(root, 'FileB.txt')
|
||||
|
||||
setupDownloadItemResponse(fileContents, false, 200, false, false)
|
||||
const downloadHttpClient = new DownloadHttpClient()
|
||||
|
||||
const items: DownloadItem[] = []
|
||||
items.push({
|
||||
sourceLocation: `${configVariables.getRuntimeUrl()}_apis/resources/Containers/13?itemPath=my-artifact%2FFileB.txt`,
|
||||
targetPath
|
||||
})
|
||||
|
||||
await expect(
|
||||
downloadHttpClient.downloadSingleArtifact(items)
|
||||
).resolves.not.toThrow()
|
||||
|
||||
await checkDestinationFile(targetPath, fileContents)
|
||||
})
|
||||
|
||||
it('Test retryable status codes during artifact download', async () => {
|
||||
// The first http response should return a retryable status call while the subsequent call should return a 200 so
|
||||
// the download should successfully finish
|
||||
const retryableStatusCodes = [429, 502, 503, 504]
|
||||
for (const statusCode of retryableStatusCodes) {
|
||||
const fileContents = Buffer.from('try, try again\n', defaultEncoding)
|
||||
const targetPath = path.join(root, `FileC-${statusCode}.txt`)
|
||||
|
||||
setupDownloadItemResponse(fileContents, false, statusCode, false, true)
|
||||
const downloadHttpClient = new DownloadHttpClient()
|
||||
|
||||
const items: DownloadItem[] = []
|
||||
items.push({
|
||||
sourceLocation: `${configVariables.getRuntimeUrl()}_apis/resources/Containers/13?itemPath=my-artifact%2FFileC.txt`,
|
||||
targetPath
|
||||
})
|
||||
|
||||
await expect(
|
||||
downloadHttpClient.downloadSingleArtifact(items)
|
||||
).resolves.not.toThrow()
|
||||
|
||||
await checkDestinationFile(targetPath, fileContents)
|
||||
}
|
||||
})
|
||||
|
||||
it('Test retry on truncated response with gzip', async () => {
|
||||
const fileContents = Buffer.from(
|
||||
'Sometimes gunzip fails on the first try\n',
|
||||
defaultEncoding
|
||||
)
|
||||
const targetPath = path.join(root, 'FileD.txt')
|
||||
|
||||
setupDownloadItemResponse(fileContents, true, 200, true, true)
|
||||
const downloadHttpClient = new DownloadHttpClient()
|
||||
|
||||
const items: DownloadItem[] = []
|
||||
items.push({
|
||||
sourceLocation: `${configVariables.getRuntimeUrl()}_apis/resources/Containers/13?itemPath=my-artifact%2FFileD.txt`,
|
||||
targetPath
|
||||
})
|
||||
|
||||
await expect(
|
||||
downloadHttpClient.downloadSingleArtifact(items)
|
||||
).resolves.not.toThrow()
|
||||
|
||||
await checkDestinationFile(targetPath, fileContents)
|
||||
})
|
||||
|
||||
it('Test retry on truncated response without gzip', async () => {
|
||||
const fileContents = Buffer.from(
|
||||
'You have to inspect the content-length header to know if you got everything\n',
|
||||
defaultEncoding
|
||||
)
|
||||
const targetPath = path.join(root, 'FileE.txt')
|
||||
|
||||
setupDownloadItemResponse(fileContents, false, 200, true, true)
|
||||
const downloadHttpClient = new DownloadHttpClient()
|
||||
|
||||
const items: DownloadItem[] = []
|
||||
items.push({
|
||||
sourceLocation: `${configVariables.getRuntimeUrl()}_apis/resources/Containers/13?itemPath=my-artifact%2FFileD.txt`,
|
||||
targetPath
|
||||
})
|
||||
|
||||
await expect(
|
||||
downloadHttpClient.downloadSingleArtifact(items)
|
||||
).resolves.not.toThrow()
|
||||
|
||||
await checkDestinationFile(targetPath, fileContents)
|
||||
})
|
||||
|
||||
/**
|
||||
* Helper used to setup mocking for the HttpClient
|
||||
*/
|
||||
async function emptyMockReadBody(): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Setups up HTTP GET response for a successful listArtifacts() call
|
||||
*/
|
||||
function setupSuccessfulListArtifactsResponse(): void {
|
||||
jest.spyOn(HttpClient.prototype, 'get').mockImplementationOnce(async () => {
|
||||
const mockMessage = new http.IncomingMessage(new net.Socket())
|
||||
let mockReadBody = emptyMockReadBody
|
||||
|
||||
mockMessage.statusCode = 201
|
||||
const response: ListArtifactsResponse = {
|
||||
count: 2,
|
||||
value: [
|
||||
{
|
||||
containerId: '13',
|
||||
size: -1,
|
||||
signedContent: 'false',
|
||||
fileContainerResourceUrl: `${configVariables.getRuntimeUrl()}_apis/resources/Containers/13`,
|
||||
type: 'actions_storage',
|
||||
name: 'artifact1-name',
|
||||
url: `${configVariables.getRuntimeUrl()}_apis/pipelines/1/runs/1/artifacts?artifactName=artifact1-name`
|
||||
},
|
||||
{
|
||||
containerId: '13',
|
||||
size: -1,
|
||||
signedContent: 'false',
|
||||
fileContainerResourceUrl: `${configVariables.getRuntimeUrl()}_apis/resources/Containers/13`,
|
||||
type: 'actions_storage',
|
||||
name: 'artifact2-name',
|
||||
url: `${configVariables.getRuntimeUrl()}_apis/pipelines/1/runs/1/artifacts?artifactName=artifact2-name`
|
||||
}
|
||||
]
|
||||
}
|
||||
const returnData: string = JSON.stringify(response, null, 2)
|
||||
mockReadBody = async function(): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
resolve(returnData)
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise<HttpClientResponse>(resolve => {
|
||||
resolve({
|
||||
message: mockMessage,
|
||||
readBody: mockReadBody
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Setups up HTTP GET response for downloading items
|
||||
* @param isGzip is the downloaded item gzip encoded
|
||||
* @param firstHttpResponseCode the http response code that should be returned
|
||||
*/
|
||||
function setupDownloadItemResponse(
|
||||
fileContents: Buffer,
|
||||
isGzip: boolean,
|
||||
firstHttpResponseCode: number,
|
||||
truncateFirstResponse: boolean,
|
||||
retryExpected: boolean
|
||||
): void {
|
||||
const spyInstance = jest
|
||||
.spyOn(HttpClient.prototype, 'get')
|
||||
.mockImplementationOnce(async () => {
|
||||
if (firstHttpResponseCode === 200) {
|
||||
const fullResponse = await constructResponse(isGzip, fileContents)
|
||||
const actualResponse = truncateFirstResponse
|
||||
? fullResponse.subarray(0, 3)
|
||||
: fullResponse
|
||||
|
||||
return {
|
||||
message: getDownloadResponseMessage(
|
||||
firstHttpResponseCode,
|
||||
isGzip,
|
||||
fullResponse.length,
|
||||
actualResponse
|
||||
),
|
||||
readBody: emptyMockReadBody
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
message: getDownloadResponseMessage(
|
||||
firstHttpResponseCode,
|
||||
false,
|
||||
0,
|
||||
null
|
||||
),
|
||||
readBody: emptyMockReadBody
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// set up a second mock only if we expect a retry. Otherwise this mock will affect other tests.
|
||||
if (retryExpected) {
|
||||
spyInstance.mockImplementationOnce(async () => {
|
||||
// chained response, if the HTTP GET function gets called again, return a successful response
|
||||
const fullResponse = await constructResponse(isGzip, fileContents)
|
||||
return {
|
||||
message: getDownloadResponseMessage(
|
||||
200,
|
||||
isGzip,
|
||||
fullResponse.length,
|
||||
fullResponse
|
||||
),
|
||||
readBody: emptyMockReadBody
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function constructResponse(
|
||||
isGzip: boolean,
|
||||
plaintext: Buffer | string
|
||||
): Promise<Buffer> {
|
||||
if (isGzip) {
|
||||
return <Buffer>await promisify(gzip)(plaintext)
|
||||
} else if (typeof plaintext === 'string') {
|
||||
return Buffer.from(plaintext, defaultEncoding)
|
||||
} else {
|
||||
return plaintext
|
||||
}
|
||||
}
|
||||
|
||||
function getDownloadResponseMessage(
|
||||
httpResponseCode: number,
|
||||
isGzip: boolean,
|
||||
contentLength: number,
|
||||
response: Buffer | null
|
||||
): http.IncomingMessage {
|
||||
let readCallCount = 0
|
||||
const mockMessage = <http.IncomingMessage>new stream.Readable({
|
||||
read(size) {
|
||||
switch (readCallCount++) {
|
||||
case 0:
|
||||
if (!!response && response.byteLength > size) {
|
||||
throw new Error(
|
||||
`test response larger than requested size (${size})`
|
||||
)
|
||||
}
|
||||
this.push(response)
|
||||
break
|
||||
|
||||
default:
|
||||
// end the stream
|
||||
this.push(null)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
mockMessage.statusCode = httpResponseCode
|
||||
mockMessage.headers = {
|
||||
'content-length': contentLength.toString()
|
||||
}
|
||||
|
||||
if (isGzip) {
|
||||
mockMessage.headers['content-encoding'] = 'gzip'
|
||||
}
|
||||
|
||||
return mockMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Setups up HTTP GET response when querying for container items
|
||||
*/
|
||||
function setupSuccessfulContainerItemsResponse(): void {
|
||||
jest.spyOn(HttpClient.prototype, 'get').mockImplementationOnce(async () => {
|
||||
const mockMessage = new http.IncomingMessage(new net.Socket())
|
||||
let mockReadBody = emptyMockReadBody
|
||||
|
||||
mockMessage.statusCode = 201
|
||||
const response: QueryArtifactResponse = {
|
||||
count: 2,
|
||||
value: [
|
||||
{
|
||||
containerId: 10000,
|
||||
scopeIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
path: 'artifact-name',
|
||||
itemType: 'folder',
|
||||
status: 'created',
|
||||
dateCreated: '2020-02-06T22:13:35.373Z',
|
||||
dateLastModified: '2020-02-06T22:13:35.453Z',
|
||||
createdBy: '82f0bf89-6e55-4e5a-b8b6-f75eb992578c',
|
||||
lastModifiedBy: '82f0bf89-6e55-4e5a-b8b6-f75eb992578c',
|
||||
itemLocation: `${configVariables.getRuntimeUrl()}/_apis/resources/Containers/10000?itemPath=artifact-name&metadata=True`,
|
||||
contentLocation: `${configVariables.getRuntimeUrl()}/_apis/resources/Containers/10000?itemPath=artifact-name`,
|
||||
contentId: ''
|
||||
},
|
||||
{
|
||||
containerId: 10000,
|
||||
scopeIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
path: 'artifact-name/file1.txt',
|
||||
itemType: 'file',
|
||||
status: 'created',
|
||||
dateCreated: '2020-02-06T22:13:35.373Z',
|
||||
dateLastModified: '2020-02-06T22:13:35.453Z',
|
||||
createdBy: '82f0bf89-6e55-4e5a-b8b6-f75eb992578c',
|
||||
lastModifiedBy: '82f0bf89-6e55-4e5a-b8b6-f75eb992578c',
|
||||
itemLocation: `${configVariables.getRuntimeUrl()}/_apis/resources/Containers/10000?itemPath=artifact-name%2Ffile1.txt&metadata=True`,
|
||||
contentLocation: `${configVariables.getRuntimeUrl()}/_apis/resources/Containers/10000?itemPath=artifact-name%2Ffile1.txt`,
|
||||
contentId: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
const returnData: string = JSON.stringify(response, null, 2)
|
||||
mockReadBody = async function(): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
resolve(returnData)
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise<HttpClientResponse>(resolve => {
|
||||
resolve({
|
||||
message: mockMessage,
|
||||
readBody: mockReadBody
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Setups up HTTP GET response for a generic failed request
|
||||
*/
|
||||
function setupFailedResponse(): void {
|
||||
jest.spyOn(HttpClient.prototype, 'get').mockImplementationOnce(async () => {
|
||||
const mockMessage = new http.IncomingMessage(new net.Socket())
|
||||
mockMessage.statusCode = 500
|
||||
return new Promise<HttpClientResponse>(resolve => {
|
||||
resolve({
|
||||
message: mockMessage,
|
||||
readBody: emptyMockReadBody
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function checkDestinationFile(
|
||||
targetPath: string,
|
||||
expectedContents: Buffer
|
||||
): Promise<void> {
|
||||
const fileContents = await fs.readFile(targetPath)
|
||||
|
||||
expect(fileContents.byteLength).toEqual(expectedContents.byteLength)
|
||||
expect(fileContents.equals(expectedContents)).toBeTruthy()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
validateArtifactName,
|
||||
validateFilePath
|
||||
} from '../src/internal/upload/path-and-artifact-name-validation'
|
||||
|
||||
import * as core from '@actions/core'
|
||||
|
||||
describe('Path and artifact name validation', () => {
|
||||
beforeAll(() => {
|
||||
// mock all output so that there is less noise when running tests
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'debug').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'info').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'warning').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('Check Artifact Name for any invalid characters', () => {
|
||||
const invalidNames = [
|
||||
'my\\artifact',
|
||||
'my/artifact',
|
||||
'my"artifact',
|
||||
'my:artifact',
|
||||
'my<artifact',
|
||||
'my>artifact',
|
||||
'my|artifact',
|
||||
'my*artifact',
|
||||
'my?artifact',
|
||||
''
|
||||
]
|
||||
for (const invalidName of invalidNames) {
|
||||
expect(() => {
|
||||
validateArtifactName(invalidName)
|
||||
}).toThrow()
|
||||
}
|
||||
|
||||
const validNames = [
|
||||
'my-normal-artifact',
|
||||
'myNormalArtifact',
|
||||
'm¥ñðrmålÄr†ï£å¢†'
|
||||
]
|
||||
for (const validName of validNames) {
|
||||
expect(() => {
|
||||
validateArtifactName(validName)
|
||||
}).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('Check Artifact File Path for any invalid characters', () => {
|
||||
const invalidNames = [
|
||||
'some/invalid"artifact/path',
|
||||
'some/invalid:artifact/path',
|
||||
'some/invalid<artifact/path',
|
||||
'some/invalid>artifact/path',
|
||||
'some/invalid|artifact/path',
|
||||
'some/invalid*artifact/path',
|
||||
'some/invalid?artifact/path',
|
||||
'some/invalid\rartifact/path',
|
||||
'some/invalid\nartifact/path',
|
||||
'some/invalid\r\nartifact/path',
|
||||
''
|
||||
]
|
||||
for (const invalidName of invalidNames) {
|
||||
expect(() => {
|
||||
validateFilePath(invalidName)
|
||||
}).toThrow()
|
||||
}
|
||||
|
||||
const validNames = [
|
||||
'my/perfectly-normal/artifact-path',
|
||||
'my/perfectly\\Normal/Artifact-path',
|
||||
'm¥/ñðrmål/Är†ï£å¢†'
|
||||
]
|
||||
for (const validName of validNames) {
|
||||
expect(() => {
|
||||
validateFilePath(validName)
|
||||
}).not.toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
import * as http from 'http'
|
||||
import * as net from 'net'
|
||||
import * as core from '@actions/core'
|
||||
import * as configVariables from '../src/internal/config-variables'
|
||||
import {retry} from '../src/internal/requestUtils'
|
||||
import {IHttpClientResponse} from '@actions/http-client/interfaces'
|
||||
import {HttpClientResponse} from '@actions/http-client'
|
||||
|
||||
jest.mock('../src/internal/config-variables')
|
||||
|
||||
interface ITestResult {
|
||||
responseCode: number
|
||||
errorMessage: string | null
|
||||
}
|
||||
|
||||
async function testRetry(
|
||||
responseCodes: number[],
|
||||
expectedResult: ITestResult
|
||||
): Promise<void> {
|
||||
const reverse = responseCodes.reverse() // Reverse responses since we pop from end
|
||||
if (expectedResult.errorMessage) {
|
||||
// we expect some exception to be thrown
|
||||
expect(
|
||||
retry(
|
||||
'test',
|
||||
async () => handleResponse(reverse.pop()),
|
||||
new Map(), // extra error message for any particular http codes
|
||||
configVariables.getRetryLimit()
|
||||
)
|
||||
).rejects.toThrow(expectedResult.errorMessage)
|
||||
} else {
|
||||
// we expect a correct status code to be returned
|
||||
const actualResult = await retry(
|
||||
'test',
|
||||
async () => handleResponse(reverse.pop()),
|
||||
new Map(), // extra error message for any particular http codes
|
||||
configVariables.getRetryLimit()
|
||||
)
|
||||
expect(actualResult.message.statusCode).toEqual(expectedResult.responseCode)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponse(
|
||||
testResponseCode: number | undefined
|
||||
): Promise<IHttpClientResponse> {
|
||||
if (!testResponseCode) {
|
||||
throw new Error(
|
||||
'Test incorrectly set up. reverse.pop() was called too many times so not enough test response codes were supplied'
|
||||
)
|
||||
}
|
||||
|
||||
return setupSingleMockResponse(testResponseCode)
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// mock all output so that there is less noise when running tests
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'debug').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'info').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'warning').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
/**
|
||||
* Helpers used to setup mocking for the HttpClient
|
||||
*/
|
||||
async function emptyMockReadBody(): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
|
||||
async function setupSingleMockResponse(
|
||||
statusCode: number
|
||||
): Promise<IHttpClientResponse> {
|
||||
const mockMessage = new http.IncomingMessage(new net.Socket())
|
||||
const mockReadBody = emptyMockReadBody
|
||||
mockMessage.statusCode = statusCode
|
||||
return new Promise<HttpClientResponse>(resolve => {
|
||||
resolve({
|
||||
message: mockMessage,
|
||||
readBody: mockReadBody
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test('retry works on successful response', async () => {
|
||||
await testRetry([200], {
|
||||
responseCode: 200,
|
||||
errorMessage: null
|
||||
})
|
||||
})
|
||||
|
||||
test('retry works after retryable status code', async () => {
|
||||
await testRetry([503, 200], {
|
||||
responseCode: 200,
|
||||
errorMessage: null
|
||||
})
|
||||
})
|
||||
|
||||
test('retry fails after exhausting retries', async () => {
|
||||
// __mocks__/config-variables caps the max retry count in tests to 2
|
||||
await testRetry([503, 503, 200], {
|
||||
responseCode: 200,
|
||||
errorMessage: 'test failed: Artifact service responded with 503'
|
||||
})
|
||||
})
|
||||
|
||||
test('retry fails after non-retryable status code', async () => {
|
||||
await testRetry([500, 200], {
|
||||
responseCode: 500,
|
||||
errorMessage: 'test failed: Artifact service responded with 500'
|
||||
})
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
path="$1"
|
||||
expectedContent="$2"
|
||||
|
||||
if [ "$path" == "" ]; then
|
||||
echo "File path not provided"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$expectedContent" == "" ]; then
|
||||
echo "Expected file contents not provided"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$path" ]; then
|
||||
echo "Expected file $path does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
actualContent=$(cat $path)
|
||||
if [ "$actualContent" != "$expectedContent" ];then
|
||||
echo "File contents are not correct, expected $expectedContent, received $actualContent"
|
||||
exit 1
|
||||
fi
|
||||
+84
-125
@@ -2,9 +2,11 @@ import * as io from '../../io/src/io'
|
||||
import * as path from 'path'
|
||||
import {promises as fs} from 'fs'
|
||||
import * as core from '@actions/core'
|
||||
import {getUploadSpecification} from '../src/internal/upload-specification'
|
||||
import {
|
||||
getUploadZipSpecification,
|
||||
validateRootDirectory
|
||||
} from '../src/internal/upload/upload-zip-specification'
|
||||
|
||||
const artifactName = 'my-artifact'
|
||||
const root = path.join(__dirname, '_temp', 'upload-specification')
|
||||
const goodItem1Path = path.join(
|
||||
root,
|
||||
@@ -125,31 +127,28 @@ describe('Search', () => {
|
||||
'upload-specification-invalid'
|
||||
)
|
||||
expect(() => {
|
||||
getUploadSpecification(
|
||||
artifactName,
|
||||
invalidRootDirectory,
|
||||
artifactFilesToUpload
|
||||
)
|
||||
}).toThrow(`Provided rootDirectory ${invalidRootDirectory} does not exist`)
|
||||
validateRootDirectory(invalidRootDirectory)
|
||||
}).toThrow(
|
||||
`The provided rootDirectory ${invalidRootDirectory} does not exist`
|
||||
)
|
||||
})
|
||||
|
||||
it('Upload Specification - Fail invalid rootDirectory', async () => {
|
||||
expect(() => {
|
||||
getUploadSpecification(artifactName, goodItem1Path, artifactFilesToUpload)
|
||||
validateRootDirectory(goodItem1Path)
|
||||
}).toThrow(
|
||||
`Provided rootDirectory ${goodItem1Path} is not a valid directory`
|
||||
`The provided rootDirectory ${goodItem1Path} is not a valid directory`
|
||||
)
|
||||
})
|
||||
|
||||
it('Upload Specification - File does not exist', async () => {
|
||||
const fakeFilePath = path.join(
|
||||
artifactName,
|
||||
'folder-a',
|
||||
'folder-b',
|
||||
'non-existent-file.txt'
|
||||
)
|
||||
expect(() => {
|
||||
getUploadSpecification(artifactName, root, [fakeFilePath])
|
||||
getUploadZipSpecification([fakeFilePath], root)
|
||||
}).toThrow(`File ${fakeFilePath} does not exist`)
|
||||
})
|
||||
|
||||
@@ -162,21 +161,20 @@ describe('Search', () => {
|
||||
goodItem5Path
|
||||
]
|
||||
expect(() => {
|
||||
getUploadSpecification(artifactName, folderADirectory, artifactFiles)
|
||||
getUploadZipSpecification(artifactFiles, folderADirectory)
|
||||
}).toThrow(
|
||||
`The rootDirectory: ${folderADirectory} is not a parent directory of the file: ${goodItem5Path}`
|
||||
)
|
||||
})
|
||||
|
||||
it('Upload Specification - Success', async () => {
|
||||
const specifications = getUploadSpecification(
|
||||
artifactName,
|
||||
root,
|
||||
artifactFilesToUpload
|
||||
const specifications = getUploadZipSpecification(
|
||||
artifactFilesToUpload,
|
||||
root
|
||||
)
|
||||
expect(specifications.length).toEqual(7)
|
||||
|
||||
const absolutePaths = specifications.map(item => item.absoluteFilePath)
|
||||
const absolutePaths = specifications.map(item => item.sourcePath)
|
||||
expect(absolutePaths).toContain(goodItem1Path)
|
||||
expect(absolutePaths).toContain(goodItem2Path)
|
||||
expect(absolutePaths).toContain(goodItem3Path)
|
||||
@@ -186,45 +184,38 @@ describe('Search', () => {
|
||||
expect(absolutePaths).toContain(amazingFileInFolderHPath)
|
||||
|
||||
for (const specification of specifications) {
|
||||
if (specification.absoluteFilePath === goodItem1Path) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
if (specification.sourcePath === goodItem1Path) {
|
||||
expect(specification.destinationPath).toEqual(
|
||||
path.join('/folder-a', 'folder-b', 'folder-c', 'good-item1.txt')
|
||||
)
|
||||
} else if (specification.sourcePath === goodItem2Path) {
|
||||
expect(specification.destinationPath).toEqual(
|
||||
path.join('/folder-d', 'good-item2.txt')
|
||||
)
|
||||
} else if (specification.sourcePath === goodItem3Path) {
|
||||
expect(specification.destinationPath).toEqual(
|
||||
path.join('/folder-d', 'good-item3.txt')
|
||||
)
|
||||
} else if (specification.sourcePath === goodItem4Path) {
|
||||
expect(specification.destinationPath).toEqual(
|
||||
path.join('/folder-d', 'good-item4.txt')
|
||||
)
|
||||
} else if (specification.sourcePath === goodItem5Path) {
|
||||
expect(specification.destinationPath).toEqual(
|
||||
path.join('/good-item5.txt')
|
||||
)
|
||||
} else if (specification.sourcePath === extraFileInFolderCPath) {
|
||||
expect(specification.destinationPath).toEqual(
|
||||
path.join(
|
||||
artifactName,
|
||||
'folder-a',
|
||||
'folder-b',
|
||||
'folder-c',
|
||||
'good-item1.txt'
|
||||
)
|
||||
)
|
||||
} else if (specification.absoluteFilePath === goodItem2Path) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
path.join(artifactName, 'folder-d', 'good-item2.txt')
|
||||
)
|
||||
} else if (specification.absoluteFilePath === goodItem3Path) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
path.join(artifactName, 'folder-d', 'good-item3.txt')
|
||||
)
|
||||
} else if (specification.absoluteFilePath === goodItem4Path) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
path.join(artifactName, 'folder-d', 'good-item4.txt')
|
||||
)
|
||||
} else if (specification.absoluteFilePath === goodItem5Path) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
path.join(artifactName, 'good-item5.txt')
|
||||
)
|
||||
} else if (specification.absoluteFilePath === extraFileInFolderCPath) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
path.join(
|
||||
artifactName,
|
||||
'folder-a',
|
||||
'/folder-a',
|
||||
'folder-b',
|
||||
'folder-c',
|
||||
'extra-file-in-folder-c.txt'
|
||||
)
|
||||
)
|
||||
} else if (specification.absoluteFilePath === amazingFileInFolderHPath) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
path.join(artifactName, 'folder-h', 'amazing-item.txt')
|
||||
} else if (specification.sourcePath === amazingFileInFolderHPath) {
|
||||
expect(specification.destinationPath).toEqual(
|
||||
path.join('/folder-h', 'amazing-item.txt')
|
||||
)
|
||||
} else {
|
||||
throw new Error(
|
||||
@@ -236,14 +227,13 @@ describe('Search', () => {
|
||||
|
||||
it('Upload Specification - Success with extra slash', async () => {
|
||||
const rootWithSlash = `${root}/`
|
||||
const specifications = getUploadSpecification(
|
||||
artifactName,
|
||||
rootWithSlash,
|
||||
artifactFilesToUpload
|
||||
const specifications = getUploadZipSpecification(
|
||||
artifactFilesToUpload,
|
||||
rootWithSlash
|
||||
)
|
||||
expect(specifications.length).toEqual(7)
|
||||
|
||||
const absolutePaths = specifications.map(item => item.absoluteFilePath)
|
||||
const absolutePaths = specifications.map(item => item.sourcePath)
|
||||
expect(absolutePaths).toContain(goodItem1Path)
|
||||
expect(absolutePaths).toContain(goodItem2Path)
|
||||
expect(absolutePaths).toContain(goodItem3Path)
|
||||
@@ -253,45 +243,38 @@ describe('Search', () => {
|
||||
expect(absolutePaths).toContain(amazingFileInFolderHPath)
|
||||
|
||||
for (const specification of specifications) {
|
||||
if (specification.absoluteFilePath === goodItem1Path) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
if (specification.sourcePath === goodItem1Path) {
|
||||
expect(specification.destinationPath).toEqual(
|
||||
path.join('/folder-a', 'folder-b', 'folder-c', 'good-item1.txt')
|
||||
)
|
||||
} else if (specification.sourcePath === goodItem2Path) {
|
||||
expect(specification.destinationPath).toEqual(
|
||||
path.join('/folder-d', 'good-item2.txt')
|
||||
)
|
||||
} else if (specification.sourcePath === goodItem3Path) {
|
||||
expect(specification.destinationPath).toEqual(
|
||||
path.join('/folder-d', 'good-item3.txt')
|
||||
)
|
||||
} else if (specification.sourcePath === goodItem4Path) {
|
||||
expect(specification.destinationPath).toEqual(
|
||||
path.join('/folder-d', 'good-item4.txt')
|
||||
)
|
||||
} else if (specification.sourcePath === goodItem5Path) {
|
||||
expect(specification.destinationPath).toEqual(
|
||||
path.join('/good-item5.txt')
|
||||
)
|
||||
} else if (specification.sourcePath === extraFileInFolderCPath) {
|
||||
expect(specification.destinationPath).toEqual(
|
||||
path.join(
|
||||
artifactName,
|
||||
'folder-a',
|
||||
'folder-b',
|
||||
'folder-c',
|
||||
'good-item1.txt'
|
||||
)
|
||||
)
|
||||
} else if (specification.absoluteFilePath === goodItem2Path) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
path.join(artifactName, 'folder-d', 'good-item2.txt')
|
||||
)
|
||||
} else if (specification.absoluteFilePath === goodItem3Path) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
path.join(artifactName, 'folder-d', 'good-item3.txt')
|
||||
)
|
||||
} else if (specification.absoluteFilePath === goodItem4Path) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
path.join(artifactName, 'folder-d', 'good-item4.txt')
|
||||
)
|
||||
} else if (specification.absoluteFilePath === goodItem5Path) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
path.join(artifactName, 'good-item5.txt')
|
||||
)
|
||||
} else if (specification.absoluteFilePath === extraFileInFolderCPath) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
path.join(
|
||||
artifactName,
|
||||
'folder-a',
|
||||
'/folder-a',
|
||||
'folder-b',
|
||||
'folder-c',
|
||||
'extra-file-in-folder-c.txt'
|
||||
)
|
||||
)
|
||||
} else if (specification.absoluteFilePath === amazingFileInFolderHPath) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
path.join(artifactName, 'folder-h', 'amazing-item.txt')
|
||||
} else if (specification.sourcePath === amazingFileInFolderHPath) {
|
||||
expect(specification.destinationPath).toEqual(
|
||||
path.join('/folder-h', 'amazing-item.txt')
|
||||
)
|
||||
} else {
|
||||
throw new Error(
|
||||
@@ -301,47 +284,23 @@ describe('Search', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('Upload Specification - Directories should not be included', async () => {
|
||||
it('Upload Specification - Empty Directories are included', async () => {
|
||||
const folderEPath = path.join(root, 'folder-a', 'folder-b', 'folder-e')
|
||||
const filesWithDirectory = [
|
||||
goodItem1Path,
|
||||
goodItem4Path,
|
||||
folderEPath,
|
||||
badItem3Path
|
||||
]
|
||||
const specifications = getUploadSpecification(
|
||||
artifactName,
|
||||
root,
|
||||
filesWithDirectory
|
||||
)
|
||||
expect(specifications.length).toEqual(3)
|
||||
const absolutePaths = specifications.map(item => item.absoluteFilePath)
|
||||
const filesWithDirectory = [goodItem1Path, folderEPath]
|
||||
const specifications = getUploadZipSpecification(filesWithDirectory, root)
|
||||
expect(specifications.length).toEqual(2)
|
||||
const absolutePaths = specifications.map(item => item.sourcePath)
|
||||
expect(absolutePaths).toContain(goodItem1Path)
|
||||
expect(absolutePaths).toContain(goodItem4Path)
|
||||
expect(absolutePaths).toContain(badItem3Path)
|
||||
expect(absolutePaths).toContain(null)
|
||||
|
||||
for (const specification of specifications) {
|
||||
if (specification.absoluteFilePath === goodItem1Path) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
path.join(
|
||||
artifactName,
|
||||
'folder-a',
|
||||
'folder-b',
|
||||
'folder-c',
|
||||
'good-item1.txt'
|
||||
)
|
||||
if (specification.sourcePath === goodItem1Path) {
|
||||
expect(specification.destinationPath).toEqual(
|
||||
path.join('/folder-a', 'folder-b', 'folder-c', 'good-item1.txt')
|
||||
)
|
||||
} else if (specification.absoluteFilePath === goodItem2Path) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
path.join(artifactName, 'folder-d', 'good-item2.txt')
|
||||
)
|
||||
} else if (specification.absoluteFilePath === goodItem4Path) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
path.join(artifactName, 'folder-d', 'good-item4.txt')
|
||||
)
|
||||
} else if (specification.absoluteFilePath === badItem3Path) {
|
||||
expect(specification.uploadFilePath).toEqual(
|
||||
path.join(artifactName, 'folder-f', 'bad-item3.txt')
|
||||
} else if (specification.sourcePath === null) {
|
||||
expect(specification.destinationPath).toEqual(
|
||||
path.join('/folder-a', 'folder-b', 'folder-e')
|
||||
)
|
||||
} else {
|
||||
throw new Error(
|
||||
@@ -1,494 +0,0 @@
|
||||
import * as http from 'http'
|
||||
import * as io from '../../io/src/io'
|
||||
import * as net from 'net'
|
||||
import * as path from 'path'
|
||||
import {UploadHttpClient} from '../src/internal/upload-http-client'
|
||||
import * as core from '@actions/core'
|
||||
import {promises as fs} from 'fs'
|
||||
import {getRuntimeUrl} from '../src/internal/config-variables'
|
||||
import {HttpClient, HttpClientResponse} from '@actions/http-client'
|
||||
import {
|
||||
ArtifactResponse,
|
||||
PatchArtifactSizeSuccessResponse
|
||||
} from '../src/internal/contracts'
|
||||
import {UploadSpecification} from '../src/internal/upload-specification'
|
||||
import {getArtifactUrl} from '../src/internal/utils'
|
||||
import {UploadOptions} from '../src/internal/upload-options'
|
||||
|
||||
const root = path.join(__dirname, '_temp', 'artifact-upload')
|
||||
const file1Path = path.join(root, 'file1.txt')
|
||||
const file2Path = path.join(root, 'file2.txt')
|
||||
const file3Path = path.join(root, 'folder1', 'file3.txt')
|
||||
const file4Path = path.join(root, 'folder1', 'file4.txt')
|
||||
const file5Path = path.join(root, 'folder1', 'folder2', 'folder3', 'file5.txt')
|
||||
|
||||
let file1Size = 0
|
||||
let file2Size = 0
|
||||
let file3Size = 0
|
||||
let file4Size = 0
|
||||
let file5Size = 0
|
||||
|
||||
jest.mock('../src/internal/config-variables')
|
||||
jest.mock('@actions/http-client')
|
||||
|
||||
describe('Upload Tests', () => {
|
||||
beforeAll(async () => {
|
||||
// mock all output so that there is less noise when running tests
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'debug').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'info').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'warning').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'error').mockImplementation(() => {})
|
||||
|
||||
// setup mocking for calls that got through the HttpClient
|
||||
setupHttpClientMock()
|
||||
|
||||
// clear temp directory and create files that will be "uploaded"
|
||||
await io.rmRF(root)
|
||||
await fs.mkdir(path.join(root, 'folder1', 'folder2', 'folder3'), {
|
||||
recursive: true
|
||||
})
|
||||
await fs.writeFile(file1Path, 'this is file 1')
|
||||
await fs.writeFile(file2Path, 'this is file 2')
|
||||
await fs.writeFile(file3Path, 'this is file 3')
|
||||
await fs.writeFile(file4Path, 'this is file 4')
|
||||
await fs.writeFile(file5Path, 'this is file 5')
|
||||
/*
|
||||
Directory structure for files that get created:
|
||||
root/
|
||||
file1.txt
|
||||
file2.txt
|
||||
folder1/
|
||||
file3.txt
|
||||
file4.txt
|
||||
folder2/
|
||||
folder3/
|
||||
file5.txt
|
||||
*/
|
||||
|
||||
file1Size = (await fs.stat(file1Path)).size
|
||||
file2Size = (await fs.stat(file2Path)).size
|
||||
file3Size = (await fs.stat(file3Path)).size
|
||||
file4Size = (await fs.stat(file4Path)).size
|
||||
file5Size = (await fs.stat(file5Path)).size
|
||||
})
|
||||
|
||||
/**
|
||||
* Artifact Creation Tests
|
||||
*/
|
||||
it('Create Artifact - Success', async () => {
|
||||
const artifactName = 'valid-artifact-name'
|
||||
const uploadHttpClient = new UploadHttpClient()
|
||||
const response = await uploadHttpClient.createArtifactInFileContainer(
|
||||
artifactName
|
||||
)
|
||||
expect(response.containerId).toEqual('13')
|
||||
expect(response.size).toEqual(-1)
|
||||
expect(response.signedContent).toEqual('false')
|
||||
expect(response.fileContainerResourceUrl).toEqual(
|
||||
`${getRuntimeUrl()}_apis/resources/Containers/13`
|
||||
)
|
||||
expect(response.type).toEqual('actions_storage')
|
||||
expect(response.name).toEqual(artifactName)
|
||||
expect(response.url).toEqual(
|
||||
`${getRuntimeUrl()}_apis/pipelines/1/runs/1/artifacts?artifactName=${artifactName}`
|
||||
)
|
||||
})
|
||||
|
||||
it('Create Artifact - Failure', async () => {
|
||||
const artifactName = 'invalid-artifact-name'
|
||||
const uploadHttpClient = new UploadHttpClient()
|
||||
expect(
|
||||
uploadHttpClient.createArtifactInFileContainer(artifactName)
|
||||
).rejects.toEqual(
|
||||
new Error(
|
||||
`Create Artifact Container failed: The artifact name invalid-artifact-name is not valid. Request URL ${getArtifactUrl()}`
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('Create Artifact - Retention Less Than Min Value Error', async () => {
|
||||
const artifactName = 'valid-artifact-name'
|
||||
const options: UploadOptions = {
|
||||
retentionDays: -1
|
||||
}
|
||||
const uploadHttpClient = new UploadHttpClient()
|
||||
expect(
|
||||
uploadHttpClient.createArtifactInFileContainer(artifactName, options)
|
||||
).rejects.toEqual(new Error('Invalid retention, minimum value is 1.'))
|
||||
})
|
||||
|
||||
it('Create Artifact - Storage Quota Error', async () => {
|
||||
const artifactName = 'storage-quota-hit'
|
||||
const uploadHttpClient = new UploadHttpClient()
|
||||
expect(
|
||||
uploadHttpClient.createArtifactInFileContainer(artifactName)
|
||||
).rejects.toEqual(
|
||||
new Error(
|
||||
'Create Artifact Container failed: Artifact storage quota has been hit. Unable to upload any new artifacts'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Artifact Upload Tests
|
||||
*/
|
||||
it('Upload Artifact - Success', async () => {
|
||||
/**
|
||||
* Normally search.findFilesToUpload() would be used for providing information about what to upload. These tests however
|
||||
* focuses solely on the upload APIs so searchResult[] will be hard-coded
|
||||
*/
|
||||
const artifactName = 'successful-artifact'
|
||||
const uploadSpecification: UploadSpecification[] = [
|
||||
{
|
||||
absoluteFilePath: file1Path,
|
||||
uploadFilePath: `${artifactName}/file1.txt`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file2Path,
|
||||
uploadFilePath: `${artifactName}/file2.txt`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file3Path,
|
||||
uploadFilePath: `${artifactName}/folder1/file3.txt`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file4Path,
|
||||
uploadFilePath: `${artifactName}/folder1/file4.txt`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file5Path,
|
||||
uploadFilePath: `${artifactName}/folder1/folder2/folder3/file5.txt`
|
||||
}
|
||||
]
|
||||
|
||||
const expectedTotalSize =
|
||||
file1Size + file2Size + file3Size + file4Size + file5Size
|
||||
const uploadUrl = `${getRuntimeUrl()}_apis/resources/Containers/13`
|
||||
const uploadHttpClient = new UploadHttpClient()
|
||||
const uploadResult = await uploadHttpClient.uploadArtifactToFileContainer(
|
||||
uploadUrl,
|
||||
uploadSpecification
|
||||
)
|
||||
expect(uploadResult.failedItems.length).toEqual(0)
|
||||
expect(uploadResult.uploadSize).toEqual(expectedTotalSize)
|
||||
})
|
||||
|
||||
it('Upload Artifact - Failed Single File Upload', async () => {
|
||||
const uploadSpecification: UploadSpecification[] = [
|
||||
{
|
||||
absoluteFilePath: file1Path,
|
||||
uploadFilePath: `this-file-upload-will-fail`
|
||||
}
|
||||
]
|
||||
|
||||
const uploadUrl = `${getRuntimeUrl()}_apis/resources/Containers/13`
|
||||
const uploadHttpClient = new UploadHttpClient()
|
||||
const uploadResult = await uploadHttpClient.uploadArtifactToFileContainer(
|
||||
uploadUrl,
|
||||
uploadSpecification
|
||||
)
|
||||
expect(uploadResult.failedItems.length).toEqual(1)
|
||||
expect(uploadResult.uploadSize).toEqual(0)
|
||||
})
|
||||
|
||||
it('Upload Artifact - Partial Upload Continue On Error', async () => {
|
||||
const artifactName = 'partial-artifact'
|
||||
const uploadSpecification: UploadSpecification[] = [
|
||||
{
|
||||
absoluteFilePath: file1Path,
|
||||
uploadFilePath: `${artifactName}/file1.txt`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file2Path,
|
||||
uploadFilePath: `${artifactName}/file2.txt`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file3Path,
|
||||
uploadFilePath: `${artifactName}/folder1/file3.txt`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file4Path,
|
||||
uploadFilePath: `this-file-upload-will-fail`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file5Path,
|
||||
uploadFilePath: `${artifactName}/folder1/folder2/folder3/file5.txt`
|
||||
}
|
||||
]
|
||||
|
||||
const expectedPartialSize = file1Size + file2Size + file4Size + file5Size
|
||||
const uploadUrl = `${getRuntimeUrl()}_apis/resources/Containers/13`
|
||||
const uploadHttpClient = new UploadHttpClient()
|
||||
const uploadResult = await uploadHttpClient.uploadArtifactToFileContainer(
|
||||
uploadUrl,
|
||||
uploadSpecification,
|
||||
{continueOnError: true}
|
||||
)
|
||||
expect(uploadResult.failedItems.length).toEqual(1)
|
||||
expect(uploadResult.uploadSize).toEqual(expectedPartialSize)
|
||||
})
|
||||
|
||||
it('Upload Artifact - Partial Upload Fail Fast', async () => {
|
||||
const artifactName = 'partial-artifact'
|
||||
const uploadSpecification: UploadSpecification[] = [
|
||||
{
|
||||
absoluteFilePath: file1Path,
|
||||
uploadFilePath: `${artifactName}/file1.txt`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file2Path,
|
||||
uploadFilePath: `${artifactName}/file2.txt`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file3Path,
|
||||
uploadFilePath: `${artifactName}/folder1/file3.txt`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file4Path,
|
||||
uploadFilePath: `this-file-upload-will-fail`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file5Path,
|
||||
uploadFilePath: `${artifactName}/folder1/folder2/folder3/file5.txt`
|
||||
}
|
||||
]
|
||||
|
||||
const expectedPartialSize = file1Size + file2Size + file3Size
|
||||
const uploadUrl = `${getRuntimeUrl()}_apis/resources/Containers/13`
|
||||
const uploadHttpClient = new UploadHttpClient()
|
||||
const uploadResult = await uploadHttpClient.uploadArtifactToFileContainer(
|
||||
uploadUrl,
|
||||
uploadSpecification,
|
||||
{continueOnError: false}
|
||||
)
|
||||
expect(uploadResult.failedItems.length).toEqual(2)
|
||||
expect(uploadResult.uploadSize).toEqual(expectedPartialSize)
|
||||
})
|
||||
|
||||
it('Upload Artifact - Failed upload with no options', async () => {
|
||||
const artifactName = 'partial-artifact'
|
||||
const uploadSpecification: UploadSpecification[] = [
|
||||
{
|
||||
absoluteFilePath: file1Path,
|
||||
uploadFilePath: `${artifactName}/file1.txt`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file2Path,
|
||||
uploadFilePath: `${artifactName}/file2.txt`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file3Path,
|
||||
uploadFilePath: `${artifactName}/folder1/file3.txt`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file4Path,
|
||||
uploadFilePath: `this-file-upload-will-fail`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file5Path,
|
||||
uploadFilePath: `${artifactName}/folder1/folder2/folder3/file5.txt`
|
||||
}
|
||||
]
|
||||
|
||||
const expectedPartialSize = file1Size + file2Size + file3Size + file5Size
|
||||
const uploadUrl = `${getRuntimeUrl()}_apis/resources/Containers/13`
|
||||
const uploadHttpClient = new UploadHttpClient()
|
||||
const uploadResult = await uploadHttpClient.uploadArtifactToFileContainer(
|
||||
uploadUrl,
|
||||
uploadSpecification
|
||||
)
|
||||
expect(uploadResult.failedItems.length).toEqual(1)
|
||||
expect(uploadResult.uploadSize).toEqual(expectedPartialSize)
|
||||
})
|
||||
|
||||
it('Upload Artifact - Failed upload with empty options', async () => {
|
||||
const artifactName = 'partial-artifact'
|
||||
const uploadSpecification: UploadSpecification[] = [
|
||||
{
|
||||
absoluteFilePath: file1Path,
|
||||
uploadFilePath: `${artifactName}/file1.txt`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file2Path,
|
||||
uploadFilePath: `${artifactName}/file2.txt`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file3Path,
|
||||
uploadFilePath: `${artifactName}/folder1/file3.txt`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file4Path,
|
||||
uploadFilePath: `this-file-upload-will-fail`
|
||||
},
|
||||
{
|
||||
absoluteFilePath: file5Path,
|
||||
uploadFilePath: `${artifactName}/folder1/folder2/folder3/file5.txt`
|
||||
}
|
||||
]
|
||||
|
||||
const expectedPartialSize = file1Size + file2Size + file3Size + file5Size
|
||||
const uploadUrl = `${getRuntimeUrl()}_apis/resources/Containers/13`
|
||||
const uploadHttpClient = new UploadHttpClient()
|
||||
const uploadResult = await uploadHttpClient.uploadArtifactToFileContainer(
|
||||
uploadUrl,
|
||||
uploadSpecification,
|
||||
{}
|
||||
)
|
||||
expect(uploadResult.failedItems.length).toEqual(1)
|
||||
expect(uploadResult.uploadSize).toEqual(expectedPartialSize)
|
||||
})
|
||||
|
||||
/**
|
||||
* Artifact Association Tests
|
||||
*/
|
||||
it('Associate Artifact - Success', async () => {
|
||||
const uploadHttpClient = new UploadHttpClient()
|
||||
expect(async () => {
|
||||
uploadHttpClient.patchArtifactSize(130, 'my-artifact')
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('Associate Artifact - Not Found', async () => {
|
||||
const uploadHttpClient = new UploadHttpClient()
|
||||
expect(
|
||||
uploadHttpClient.patchArtifactSize(100, 'non-existent-artifact')
|
||||
).rejects.toThrow(
|
||||
'An Artifact with the name non-existent-artifact was not found'
|
||||
)
|
||||
})
|
||||
|
||||
it('Associate Artifact - Error', async () => {
|
||||
const uploadHttpClient = new UploadHttpClient()
|
||||
expect(
|
||||
uploadHttpClient.patchArtifactSize(-2, 'my-artifact')
|
||||
).rejects.toThrow(
|
||||
'Finalize artifact upload failed: Artifact service responded with 400'
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Helpers used to setup mocking for the HttpClient
|
||||
*/
|
||||
async function emptyMockReadBody(): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
|
||||
function setupHttpClientMock(): void {
|
||||
/**
|
||||
* Mocks Post calls that are used during Artifact Creation tests
|
||||
*
|
||||
* Simulates success and non-success status codes depending on the artifact name along with an appropriate
|
||||
* payload that represents an expected response
|
||||
*/
|
||||
jest
|
||||
.spyOn(HttpClient.prototype, 'post')
|
||||
.mockImplementation(async (requestdata, data) => {
|
||||
// parse the input data and use the provided artifact name as part of the response
|
||||
const inputData = JSON.parse(data)
|
||||
const mockMessage = new http.IncomingMessage(new net.Socket())
|
||||
let mockReadBody = emptyMockReadBody
|
||||
|
||||
if (inputData.Name === 'invalid-artifact-name') {
|
||||
mockMessage.statusCode = 400
|
||||
} else if (inputData.Name === 'storage-quota-hit') {
|
||||
mockMessage.statusCode = 403
|
||||
} else {
|
||||
mockMessage.statusCode = 201
|
||||
const response: ArtifactResponse = {
|
||||
containerId: '13',
|
||||
size: -1,
|
||||
signedContent: 'false',
|
||||
fileContainerResourceUrl: `${getRuntimeUrl()}_apis/resources/Containers/13`,
|
||||
type: 'actions_storage',
|
||||
name: inputData.Name,
|
||||
url: `${getRuntimeUrl()}_apis/pipelines/1/runs/1/artifacts?artifactName=${
|
||||
inputData.Name
|
||||
}`
|
||||
}
|
||||
const returnData: string = JSON.stringify(response, null, 2)
|
||||
mockReadBody = async function(): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
resolve(returnData)
|
||||
})
|
||||
}
|
||||
}
|
||||
return new Promise<HttpClientResponse>(resolve => {
|
||||
resolve({
|
||||
message: mockMessage,
|
||||
readBody: mockReadBody
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Mocks SendStream calls that are made during Artifact Upload tests
|
||||
*
|
||||
* A 500 response is used to simulate a failed upload stream. The uploadUrl can be set to
|
||||
* include 'fail' to specify that the upload should fail
|
||||
*/
|
||||
jest
|
||||
.spyOn(HttpClient.prototype, 'sendStream')
|
||||
.mockImplementation(async (verb, requestUrl) => {
|
||||
const mockMessage = new http.IncomingMessage(new net.Socket())
|
||||
mockMessage.statusCode = 200
|
||||
if (requestUrl.includes('fail')) {
|
||||
mockMessage.statusCode = 500
|
||||
}
|
||||
|
||||
return new Promise<HttpClientResponse>(resolve => {
|
||||
resolve({
|
||||
message: mockMessage,
|
||||
readBody: emptyMockReadBody
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Mocks Patch calls that are made during Artifact Association tests
|
||||
*
|
||||
* Simulates success and non-success status codes depending on the input size along with an appropriate
|
||||
* payload that represents an expected response
|
||||
*/
|
||||
jest
|
||||
.spyOn(HttpClient.prototype, 'patch')
|
||||
.mockImplementation(async (requestdata, data) => {
|
||||
const inputData = JSON.parse(data)
|
||||
const mockMessage = new http.IncomingMessage(new net.Socket())
|
||||
|
||||
// Get the name from the end of requestdata. Will be something like https://www.example.com/_apis/pipelines/workflows/15/artifacts?api-version=6.0-preview&artifactName=my-artifact
|
||||
const artifactName = requestdata.split('=')[2]
|
||||
let mockReadBody = emptyMockReadBody
|
||||
if (inputData.Size < 1) {
|
||||
mockMessage.statusCode = 400
|
||||
} else if (artifactName === 'non-existent-artifact') {
|
||||
mockMessage.statusCode = 404
|
||||
} else {
|
||||
mockMessage.statusCode = 200
|
||||
const response: PatchArtifactSizeSuccessResponse = {
|
||||
containerId: 13,
|
||||
size: inputData.Size,
|
||||
signedContent: 'false',
|
||||
type: 'actions_storage',
|
||||
name: artifactName,
|
||||
url: `${getRuntimeUrl()}_apis/pipelines/1/runs/1/artifacts?artifactName=${artifactName}`,
|
||||
uploadUrl: `${getRuntimeUrl()}_apis/resources/Containers/13`
|
||||
}
|
||||
const returnData: string = JSON.stringify(response, null, 2)
|
||||
mockReadBody = async function(): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
resolve(returnData)
|
||||
})
|
||||
}
|
||||
}
|
||||
return new Promise<HttpClientResponse>(resolve => {
|
||||
resolve({
|
||||
message: mockMessage,
|
||||
readBody: mockReadBody
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,282 +0,0 @@
|
||||
import * as fs from 'fs'
|
||||
import * as io from '../../io/src/io'
|
||||
import * as path from 'path'
|
||||
import * as utils from '../src/internal/utils'
|
||||
import * as core from '@actions/core'
|
||||
import {HttpCodes} from '@actions/http-client'
|
||||
import {
|
||||
getRuntimeUrl,
|
||||
getWorkFlowRunId,
|
||||
getInitialRetryIntervalInMilliseconds,
|
||||
getRetryMultiplier
|
||||
} from '../src/internal/config-variables'
|
||||
|
||||
jest.mock('../src/internal/config-variables')
|
||||
|
||||
describe('Utils', () => {
|
||||
beforeAll(() => {
|
||||
// mock all output so that there is less noise when running tests
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'debug').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'info').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'warning').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('Check exponential retry range', () => {
|
||||
// No retries should return the initial retry interval
|
||||
const retryWaitTime0 = utils.getExponentialRetryTimeInMilliseconds(0)
|
||||
expect(retryWaitTime0).toEqual(getInitialRetryIntervalInMilliseconds())
|
||||
|
||||
const testMinMaxRange = (retryCount: number): void => {
|
||||
const retryWaitTime = utils.getExponentialRetryTimeInMilliseconds(
|
||||
retryCount
|
||||
)
|
||||
const minRange =
|
||||
getInitialRetryIntervalInMilliseconds() *
|
||||
getRetryMultiplier() *
|
||||
retryCount
|
||||
const maxRange = minRange * getRetryMultiplier()
|
||||
|
||||
expect(retryWaitTime).toBeGreaterThanOrEqual(minRange)
|
||||
expect(retryWaitTime).toBeLessThan(maxRange)
|
||||
}
|
||||
|
||||
for (let i = 1; i < 10; i++) {
|
||||
testMinMaxRange(i)
|
||||
}
|
||||
})
|
||||
|
||||
it('Check Artifact Name for any invalid characters', () => {
|
||||
const invalidNames = [
|
||||
'my\\artifact',
|
||||
'my/artifact',
|
||||
'my"artifact',
|
||||
'my:artifact',
|
||||
'my<artifact',
|
||||
'my>artifact',
|
||||
'my|artifact',
|
||||
'my*artifact',
|
||||
'my?artifact',
|
||||
''
|
||||
]
|
||||
for (const invalidName of invalidNames) {
|
||||
expect(() => {
|
||||
utils.checkArtifactName(invalidName)
|
||||
}).toThrow()
|
||||
}
|
||||
|
||||
const validNames = [
|
||||
'my-normal-artifact',
|
||||
'myNormalArtifact',
|
||||
'm¥ñðrmålÄr†ï£å¢†'
|
||||
]
|
||||
for (const validName of validNames) {
|
||||
expect(() => {
|
||||
utils.checkArtifactName(validName)
|
||||
}).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('Check Artifact File Path for any invalid characters', () => {
|
||||
const invalidNames = [
|
||||
'some/invalid"artifact/path',
|
||||
'some/invalid:artifact/path',
|
||||
'some/invalid<artifact/path',
|
||||
'some/invalid>artifact/path',
|
||||
'some/invalid|artifact/path',
|
||||
'some/invalid*artifact/path',
|
||||
'some/invalid?artifact/path',
|
||||
''
|
||||
]
|
||||
for (const invalidName of invalidNames) {
|
||||
expect(() => {
|
||||
utils.checkArtifactFilePath(invalidName)
|
||||
}).toThrow()
|
||||
}
|
||||
|
||||
const validNames = [
|
||||
'my/perfectly-normal/artifact-path',
|
||||
'my/perfectly\\Normal/Artifact-path',
|
||||
'm¥/ñðrmål/Är†ï£å¢†'
|
||||
]
|
||||
for (const validName of validNames) {
|
||||
expect(() => {
|
||||
utils.checkArtifactFilePath(validName)
|
||||
}).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('Test negative artifact retention throws', () => {
|
||||
expect(() => {
|
||||
utils.getProperRetention(-1, undefined)
|
||||
}).toThrow()
|
||||
})
|
||||
|
||||
it('Test no setting specified takes artifact retention input', () => {
|
||||
expect(utils.getProperRetention(180, undefined)).toEqual(180)
|
||||
})
|
||||
|
||||
it('Test artifact retention must conform to max allowed', () => {
|
||||
expect(utils.getProperRetention(180, '45')).toEqual(45)
|
||||
})
|
||||
|
||||
it('Test constructing artifact URL', () => {
|
||||
const runtimeUrl = getRuntimeUrl()
|
||||
const runId = getWorkFlowRunId()
|
||||
const artifactUrl = utils.getArtifactUrl()
|
||||
expect(artifactUrl).toEqual(
|
||||
`${runtimeUrl}_apis/pipelines/workflows/${runId}/artifacts?api-version=${utils.getApiVersion()}`
|
||||
)
|
||||
})
|
||||
|
||||
it('Test constructing upload headers with all optional parameters', () => {
|
||||
const contentType = 'application/octet-stream'
|
||||
const size = 24
|
||||
const uncompressedLength = 100
|
||||
const range = 'bytes 0-199/200'
|
||||
const headers = utils.getUploadHeaders(
|
||||
contentType,
|
||||
true,
|
||||
true,
|
||||
uncompressedLength,
|
||||
size,
|
||||
range
|
||||
)
|
||||
expect(Object.keys(headers).length).toEqual(8)
|
||||
expect(headers['Accept']).toEqual(
|
||||
`application/json;api-version=${utils.getApiVersion()}`
|
||||
)
|
||||
expect(headers['Content-Type']).toEqual(contentType)
|
||||
expect(headers['Connection']).toEqual('Keep-Alive')
|
||||
expect(headers['Keep-Alive']).toEqual('10')
|
||||
expect(headers['Content-Encoding']).toEqual('gzip')
|
||||
expect(headers['x-tfs-filelength']).toEqual(uncompressedLength)
|
||||
expect(headers['Content-Length']).toEqual(size)
|
||||
expect(headers['Content-Range']).toEqual(range)
|
||||
})
|
||||
|
||||
it('Test constructing upload headers with only required parameter', () => {
|
||||
const headers = utils.getUploadHeaders('application/octet-stream')
|
||||
expect(Object.keys(headers).length).toEqual(2)
|
||||
expect(headers['Accept']).toEqual(
|
||||
`application/json;api-version=${utils.getApiVersion()}`
|
||||
)
|
||||
expect(headers['Content-Type']).toEqual('application/octet-stream')
|
||||
})
|
||||
|
||||
it('Test constructing download headers with all optional parameters', () => {
|
||||
const contentType = 'application/json'
|
||||
const headers = utils.getDownloadHeaders(contentType, true, true)
|
||||
expect(Object.keys(headers).length).toEqual(5)
|
||||
expect(headers['Content-Type']).toEqual(contentType)
|
||||
expect(headers['Connection']).toEqual('Keep-Alive')
|
||||
expect(headers['Keep-Alive']).toEqual('10')
|
||||
expect(headers['Accept-Encoding']).toEqual('gzip')
|
||||
expect(headers['Accept']).toEqual(
|
||||
`application/octet-stream;api-version=${utils.getApiVersion()}`
|
||||
)
|
||||
})
|
||||
|
||||
it('Test constructing download headers with only required parameter', () => {
|
||||
const headers = utils.getDownloadHeaders('application/octet-stream')
|
||||
expect(Object.keys(headers).length).toEqual(2)
|
||||
expect(headers['Content-Type']).toEqual('application/octet-stream')
|
||||
// check for default accept type
|
||||
expect(headers['Accept']).toEqual(
|
||||
`application/json;api-version=${utils.getApiVersion()}`
|
||||
)
|
||||
})
|
||||
|
||||
it('Test Success Status Code', () => {
|
||||
expect(utils.isSuccessStatusCode(HttpCodes.OK)).toEqual(true)
|
||||
expect(utils.isSuccessStatusCode(201)).toEqual(true)
|
||||
expect(utils.isSuccessStatusCode(299)).toEqual(true)
|
||||
expect(utils.isSuccessStatusCode(HttpCodes.NotFound)).toEqual(false)
|
||||
expect(utils.isSuccessStatusCode(HttpCodes.BadGateway)).toEqual(false)
|
||||
expect(utils.isSuccessStatusCode(HttpCodes.Forbidden)).toEqual(false)
|
||||
})
|
||||
|
||||
it('Test Retry Status Code', () => {
|
||||
expect(utils.isRetryableStatusCode(HttpCodes.BadGateway)).toEqual(true)
|
||||
expect(utils.isRetryableStatusCode(HttpCodes.ServiceUnavailable)).toEqual(
|
||||
true
|
||||
)
|
||||
expect(utils.isRetryableStatusCode(HttpCodes.GatewayTimeout)).toEqual(true)
|
||||
expect(utils.isRetryableStatusCode(HttpCodes.TooManyRequests)).toEqual(true)
|
||||
expect(utils.isRetryableStatusCode(HttpCodes.OK)).toEqual(false)
|
||||
expect(utils.isRetryableStatusCode(HttpCodes.NotFound)).toEqual(false)
|
||||
expect(utils.isRetryableStatusCode(HttpCodes.Forbidden)).toEqual(false)
|
||||
expect(utils.isRetryableStatusCode(413)).toEqual(true) // Payload Too Large
|
||||
})
|
||||
|
||||
it('Test Throttled Status Code', () => {
|
||||
expect(utils.isThrottledStatusCode(HttpCodes.TooManyRequests)).toEqual(true)
|
||||
expect(utils.isThrottledStatusCode(HttpCodes.InternalServerError)).toEqual(
|
||||
false
|
||||
)
|
||||
expect(utils.isThrottledStatusCode(HttpCodes.BadGateway)).toEqual(false)
|
||||
expect(utils.isThrottledStatusCode(HttpCodes.ServiceUnavailable)).toEqual(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('Test Forbidden Status Code', () => {
|
||||
expect(utils.isForbiddenStatusCode(HttpCodes.Forbidden)).toEqual(true)
|
||||
expect(utils.isForbiddenStatusCode(HttpCodes.InternalServerError)).toEqual(
|
||||
false
|
||||
)
|
||||
expect(utils.isForbiddenStatusCode(HttpCodes.TooManyRequests)).toEqual(
|
||||
false
|
||||
)
|
||||
expect(utils.isForbiddenStatusCode(HttpCodes.OK)).toEqual(false)
|
||||
})
|
||||
|
||||
it('Test Creating Artifact Directories', async () => {
|
||||
const root = path.join(__dirname, '_temp', 'artifact-download')
|
||||
// remove directory before starting
|
||||
await io.rmRF(root)
|
||||
|
||||
const directory1 = path.join(root, 'folder2', 'folder3')
|
||||
const directory2 = path.join(directory1, 'folder1')
|
||||
|
||||
// Initially should not exist
|
||||
await expect(fs.promises.access(directory1)).rejects.not.toBeUndefined()
|
||||
await expect(fs.promises.access(directory2)).rejects.not.toBeUndefined()
|
||||
const directoryStructure = [directory1, directory2]
|
||||
await utils.createDirectoriesForArtifact(directoryStructure)
|
||||
// directories should now be created
|
||||
await expect(fs.promises.access(directory1)).resolves.toEqual(undefined)
|
||||
await expect(fs.promises.access(directory2)).resolves.toEqual(undefined)
|
||||
})
|
||||
|
||||
it('Test Creating Empty Files', async () => {
|
||||
const root = path.join(__dirname, '_temp', 'empty-files')
|
||||
await io.rmRF(root)
|
||||
|
||||
const emptyFile1 = path.join(root, 'emptyFile1')
|
||||
const directoryToCreate = path.join(root, 'folder1')
|
||||
const emptyFile2 = path.join(directoryToCreate, 'emptyFile2')
|
||||
|
||||
// empty files should only be created after the directory structure is fully setup
|
||||
// ensure they are first created by using the createDirectoriesForArtifact method
|
||||
const directoryStructure = [root, directoryToCreate]
|
||||
await utils.createDirectoriesForArtifact(directoryStructure)
|
||||
await expect(fs.promises.access(root)).resolves.toEqual(undefined)
|
||||
await expect(fs.promises.access(directoryToCreate)).resolves.toEqual(
|
||||
undefined
|
||||
)
|
||||
|
||||
await expect(fs.promises.access(emptyFile1)).rejects.not.toBeUndefined()
|
||||
await expect(fs.promises.access(emptyFile2)).rejects.not.toBeUndefined()
|
||||
|
||||
const emptyFilesToCreate = [emptyFile1, emptyFile2]
|
||||
await utils.createEmptyFilesForArtifact(emptyFilesToCreate)
|
||||
|
||||
await expect(fs.promises.access(emptyFile1)).resolves.toEqual(undefined)
|
||||
const size1 = (await fs.promises.stat(emptyFile1)).size
|
||||
expect(size1).toEqual(0)
|
||||
await expect(fs.promises.access(emptyFile2)).resolves.toEqual(undefined)
|
||||
const size2 = (await fs.promises.stat(emptyFile2)).size
|
||||
expect(size2).toEqual(0)
|
||||
})
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
# Additional Information
|
||||
|
||||
Extra information
|
||||
- [Non-Supported Characters](#Non-Supported-Characters)
|
||||
- [Permission loss](#Permission-Loss)
|
||||
- [Considerations](#Considerations)
|
||||
- [Compression](#Is-my-artifact-compressed)
|
||||
|
||||
## Non-Supported Characters
|
||||
|
||||
When uploading an artifact, the inputted `name` parameter along with the files specified in `files` cannot contain any of the following characters. They will be rejected by the server if attempted to be sent over and the upload will fail. These characters are not allowed due to limitations and restrictions with certain file systems such as NTFS. To maintain platform-agnostic behavior, all characters that are not supported by an individual filesystem/platform will not be supported on all filesystems/platforms.
|
||||
|
||||
- "
|
||||
- :
|
||||
- <
|
||||
- \>
|
||||
- |
|
||||
- \*
|
||||
- ?
|
||||
|
||||
In addition to the aforementioned characters, the inputted `name` also cannot include the following
|
||||
- \
|
||||
- /
|
||||
|
||||
|
||||
## Permission Loss
|
||||
|
||||
File permissions are not maintained between uploaded and downloaded artifacts. If file permissions are something that need to be maintained (such as an executable), consider archiving all of the files using something like `tar` and then uploading the single archive. After downloading the artifact, you can `un-tar` the individual file and permissions will be preserved.
|
||||
|
||||
```js
|
||||
const artifact = require('@actions/artifact');
|
||||
const artifactClient = artifact.create()
|
||||
const artifactName = 'my-artifact';
|
||||
const files = [
|
||||
'/home/user/files/plz-upload/my-archive.tgz',
|
||||
]
|
||||
const rootDirectory = '/home/user/files/plz-upload'
|
||||
const uploadResult = await artifactClient.uploadArtifact(artifactName, files, rootDirectory)
|
||||
```
|
||||
|
||||
## Considerations
|
||||
|
||||
During upload, each file is uploaded concurrently in 4MB chunks using a separate HTTPS connection per file. Chunked uploads are used so that in the event of a failure (which is entirely possible because the internet is not perfect), the upload can be retried. If there is an error, a retry will be attempted after a certain period of time.
|
||||
|
||||
Uploading will be generally be faster if there are fewer files that are larger in size vs if there are lots of smaller files. Depending on the types and quantities of files being uploaded, it might be beneficial to separately compress and archive everything into a single archive (using something like `tar` or `zip`) before starting and artifact upload to speed things up.
|
||||
|
||||
## Is my artifact compressed?
|
||||
|
||||
GZip is used internally to compress individual files before starting an upload. Compression helps reduce the total amount of data that must be uploaded and stored while helping to speed up uploads (this performance benefit is significant especially on self hosted runners). If GZip does not reduce the size of the file that is being uploaded, the original file is uploaded as-is.
|
||||
|
||||
Compression using GZip also helps speed up artifact download as part of a workflow. Header information is used to determine if an individual file was uploaded using GZip and if necessary, decompression is used.
|
||||
|
||||
When downloading an artifact from the GitHub UI (this differs from downloading an artifact during a workflow), a single Zip file is dynamically created that contains all of the files uploaded as part of an artifact. Any files that were uploaded using GZip will be decompressed on the server before being added to the Zip file with the remaining files.
|
||||
@@ -0,0 +1 @@
|
||||
Docs will be added here once development of version `2.0.0` has finished
|
||||
@@ -1,57 +0,0 @@
|
||||
# Implementation Details
|
||||
|
||||
Warning: Implementation details may change at any time without notice. This is meant to serve as a reference to help users understand the package.
|
||||
|
||||
## Upload/Compression flow
|
||||
|
||||

|
||||
|
||||
During artifact upload, gzip is used to compress individual files that then get uploaded. This is used to minimize the amount of data that gets uploaded which reduces the total amount of HTTP calls (upload happens in 4MB chunks). This results in considerably faster uploads with huge performance implications especially on self-hosted runners.
|
||||
|
||||
If a file is less than 64KB in size, a passthrough stream (readable and writable) is used to convert an in-memory buffer into a readable stream without any extra streams or pipping.
|
||||
|
||||
## Retry Logic when downloading an individual file
|
||||
|
||||

|
||||
|
||||
## Proxy support
|
||||
|
||||
This package uses the `@actions/http-client` NPM package internally which supports proxied requests out of the box.
|
||||
|
||||
## HttpManager
|
||||
|
||||
### `keep-alive` header
|
||||
|
||||
When an HTTP call is made to upload or download an individual file, the server will close the HTTP connection after the upload/download is complete and respond with a header indicating `Connection: close`.
|
||||
|
||||
[HTTP closed connection header information](https://tools.ietf.org/html/rfc2616#section-14.10)
|
||||
|
||||
TCP connections are sometimes not immediately closed by the node client (Windows might hold on to the port for an extra period of time before actually releasing it for example) and a large amount of closed connections can cause port exhaustion before ports get released and are available again.
|
||||
|
||||
VMs hosted by GitHub Actions have 1024 available ports so uploading 1000+ files very quickly can cause port exhaustion if connections get closed immediately. This can start to cause strange undefined behavior and timeouts.
|
||||
|
||||
In order for connections to not close immediately, the `keep-alive` header is used to indicate to the server that the connection should stay open. If a `keep-alive` header is used, the connection needs to be disposed of by calling `dispose()` in the `HttpClient`.
|
||||
|
||||
[`keep-alive` header information](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Keep-Alive)
|
||||
[@actions/http-client client disposal](https://github.com/actions/http-client/blob/04e5ad73cd3fd1f5610a32116b0759eddf6570d2/index.ts#L292)
|
||||
|
||||
|
||||
### Multiple HTTP clients
|
||||
|
||||
During an artifact upload or download, files are concurrently uploaded or downloaded using `async/await`. When an error or retry is encountered, the `HttpClient` that made a call is disposed of and a new one is created. If a single `HttpClient` was used for all HTTP calls and it had to be disposed, it could inadvertently effect any other calls that could be concurrently happening.
|
||||
|
||||
Any other concurrent uploads or downloads should be left untouched. Because of this, each concurrent upload or download gets its own `HttpClient`. The `http-manager` is used to manage all available clients and each concurrent upload or download maintains a `httpClientIndex` that keep track of which client should be used (and potentially disposed and recycled if necessary)
|
||||
|
||||
### Potential resource leaks
|
||||
|
||||
When an HTTP response is received, it consists of two parts
|
||||
- `message`
|
||||
- `body`
|
||||
|
||||
The `message` contains information such as the response code and header information and it is available immediately. The body however is not available immediately and it can be read by calling `await response.readBody()`.
|
||||
|
||||
TCP connections consist of an input and output buffer to manage what is sent and received across a connection. If the body is not read (even if its contents are not needed) the buffers can stay in use even after `dispose()` gets called on the `HttpClient`. The buffers get released automatically after a certain period of time, but in order for them to be explicitly cleared, `readBody()` is always called.
|
||||
|
||||
### Non Concurrent calls
|
||||
|
||||
Both `upload-http-client` and `download-http-client` do not instantiate or create any HTTP clients (the `HttpManager` has that responsibility). If an HTTP call has to be made that does not require the `keep-alive` header (such as when calling `listArtifacts` or `patchArtifactSize`), the first `HttpClient` in the `HttpManager` is used. The number of available clients is equal to the upload or download concurrency and there will always be at least one available.
|
||||
Generated
+59
-124
@@ -1,138 +1,73 @@
|
||||
{
|
||||
"name": "@actions/artifact",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 1,
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
"@actions/core": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.2.6.tgz",
|
||||
"integrity": "sha512-ZQYitnqiyBc3D+k7LsgSBmMDVkOVidaagDG7j3fOym77jNunWRuYx7VSHa9GNfFZh+zh61xsCjRj4JxMZlDqTA=="
|
||||
},
|
||||
"@actions/http-client": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.8.tgz",
|
||||
"integrity": "sha512-G4JjJ6f9Hb3Zvejj+ewLLKLf99ZC+9v+yCxoYf9vSyH+WkzPLB2LuUtRMGNkooMqdugGBFStIKXOuvH1W+EctA==",
|
||||
"requires": {
|
||||
"tunnel": "0.0.6"
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@actions/artifact",
|
||||
"version": "2.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.0",
|
||||
"@actions/http-client": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/tmp": "^0.2.1",
|
||||
"typescript": "^4.3.0"
|
||||
}
|
||||
},
|
||||
"@types/tmp": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.1.0.tgz",
|
||||
"integrity": "sha512-6IwZ9HzWbCq6XoQWhxLpDjuADodH/MKXRUIDFudvgjcVdjFknvmR+DNsoUeer4XPrEnrZs04Jj+kfV9pFsrhmA=="
|
||||
},
|
||||
"balanced-match": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
|
||||
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
"node_modules/@actions/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-2aZDDa3zrrZbP5ZYg159sNoLRb61nQ7awl5pSvIq5Qpj81vwDzdMRKzkWJGJuwVvWpvZKx7vspJALyvaaIQyug==",
|
||||
"dependencies": {
|
||||
"@actions/http-client": "^2.0.1",
|
||||
"uuid": "^8.3.2"
|
||||
}
|
||||
},
|
||||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
|
||||
},
|
||||
"fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
|
||||
},
|
||||
"glob": {
|
||||
"version": "7.1.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
|
||||
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
|
||||
"requires": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.0.4",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
"node_modules/@actions/http-client": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.0.tgz",
|
||||
"integrity": "sha512-BonhODnXr3amchh4qkmjPMUO8mFi/zLaaCeCAJZqch8iQqyDnVIkySjB38VHAC8IJ+bnlgfOqlhpyCUZHlQsqw==",
|
||||
"dependencies": {
|
||||
"tunnel": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
|
||||
"requires": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
|
||||
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
|
||||
"requires": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
|
||||
},
|
||||
"rimraf": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
|
||||
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
|
||||
"requires": {
|
||||
"glob": "^7.1.3"
|
||||
}
|
||||
},
|
||||
"tmp": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.1.0.tgz",
|
||||
"integrity": "sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw==",
|
||||
"requires": {
|
||||
"rimraf": "^2.6.3"
|
||||
}
|
||||
},
|
||||
"tmp-promise": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-2.0.2.tgz",
|
||||
"integrity": "sha512-zl71nFWjPKW2KXs+73gEk8RmqvtAeXPxhWDkTUoa3MSMkjq3I+9OeknjF178MQoMYsdqL730hfzvNfEkePxq9Q==",
|
||||
"requires": {
|
||||
"tmp": "0.1.0"
|
||||
}
|
||||
},
|
||||
"tunnel": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
|
||||
"integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="
|
||||
},
|
||||
"typescript": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.8.3.tgz",
|
||||
"integrity": "sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==",
|
||||
"node_modules/@types/tmp": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.3.tgz",
|
||||
"integrity": "sha512-dDZH/tXzwjutnuk4UacGgFRwV+JSLaXL1ikvidfJprkb7L9Nx1njcRHHmi3Dsvt7pgqqTEeucQuOrWHPFgzVHA==",
|
||||
"dev": true
|
||||
},
|
||||
"wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
|
||||
"node_modules/tunnel": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
|
||||
"integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
|
||||
"engines": {
|
||||
"node": ">=0.6.11 <=0.7.0 || >=0.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "4.9.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/artifact",
|
||||
"version": "0.5.0",
|
||||
"version": "2.0.0",
|
||||
"preview": true,
|
||||
"description": "Actions artifact lib",
|
||||
"keywords": [
|
||||
@@ -31,19 +31,19 @@
|
||||
"scripts": {
|
||||
"audit-moderate": "npm install && npm audit --json --audit-level=moderate > audit.json",
|
||||
"test": "echo \"Error: run tests from root\" && exit 1",
|
||||
"tsc": "tsc"
|
||||
"bootstrap": "cd ../../ && npm run bootstrap",
|
||||
"tsc-run": "tsc",
|
||||
"tsc": "npm run bootstrap && npm run tsc-run"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/actions/toolkit/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.2.6",
|
||||
"@actions/http-client": "^1.0.7",
|
||||
"@types/tmp": "^0.1.0",
|
||||
"tmp": "^0.1.0",
|
||||
"tmp-promise": "^2.0.2"
|
||||
"@actions/core": "^1.10.0",
|
||||
"@actions/http-client": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^3.8.3"
|
||||
"@types/tmp": "^0.2.1",
|
||||
"typescript": "^4.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import {UploadOptions} from './internal/upload-options'
|
||||
import {UploadResponse} from './internal/upload-response'
|
||||
import {DownloadOptions} from './internal/download-options'
|
||||
import {DownloadResponse} from './internal/download-response'
|
||||
import {ArtifactClient, DefaultArtifactClient} from './internal/artifact-client'
|
||||
|
||||
export {
|
||||
ArtifactClient,
|
||||
UploadResponse,
|
||||
UploadOptions,
|
||||
DownloadResponse,
|
||||
DownloadOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an ArtifactClient
|
||||
*/
|
||||
export function create(): ArtifactClient {
|
||||
return DefaultArtifactClient.create()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {ArtifactClient, Client} from './internal/client'
|
||||
import {UploadOptions} from './internal/upload/upload-options'
|
||||
import {UploadResponse} from './internal/upload/upload-response'
|
||||
|
||||
/**
|
||||
* Exported functionality that we want to expose for any users of @actions/artifact
|
||||
*/
|
||||
export {ArtifactClient, UploadOptions, UploadResponse}
|
||||
|
||||
export function create(): ArtifactClient {
|
||||
return Client.create()
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Mocks default limits for easier testing
|
||||
*/
|
||||
export function getUploadFileConcurrency(): number {
|
||||
return 1
|
||||
}
|
||||
|
||||
export function getUploadChunkConcurrency(): number {
|
||||
return 1
|
||||
}
|
||||
|
||||
export function getUploadChunkSize(): number {
|
||||
return 4 * 1024 * 1024 // 4 MB Chunks
|
||||
}
|
||||
|
||||
export function getRetryLimit(): number {
|
||||
return 2
|
||||
}
|
||||
|
||||
export function getRetryMultiplier(): number {
|
||||
return 1.5
|
||||
}
|
||||
|
||||
export function getInitialRetryIntervalInMilliseconds(): number {
|
||||
return 10
|
||||
}
|
||||
|
||||
export function getDownloadFileConcurrency(): number {
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks the 'ACTIONS_RUNTIME_TOKEN', 'ACTIONS_RUNTIME_URL' and 'GITHUB_RUN_ID' env variables
|
||||
* that are only available from a node context on the runner. This allows for tests to run
|
||||
* locally without the env variables actually being set
|
||||
*/
|
||||
export function getRuntimeToken(): string {
|
||||
return 'totally-valid-token'
|
||||
}
|
||||
|
||||
export function getRuntimeUrl(): string {
|
||||
return 'https://www.example.com/'
|
||||
}
|
||||
|
||||
export function getWorkFlowRunId(): string {
|
||||
return '15'
|
||||
}
|
||||
|
||||
export function getRetentionDays(): string | undefined {
|
||||
return '45'
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
import * as core from '@actions/core'
|
||||
import {
|
||||
UploadSpecification,
|
||||
getUploadSpecification
|
||||
} from './upload-specification'
|
||||
import {UploadHttpClient} from './upload-http-client'
|
||||
import {UploadResponse} from './upload-response'
|
||||
import {UploadOptions} from './upload-options'
|
||||
import {DownloadOptions} from './download-options'
|
||||
import {DownloadResponse} from './download-response'
|
||||
import {
|
||||
checkArtifactName,
|
||||
createDirectoriesForArtifact,
|
||||
createEmptyFilesForArtifact
|
||||
} from './utils'
|
||||
import {DownloadHttpClient} from './download-http-client'
|
||||
import {getDownloadSpecification} from './download-specification'
|
||||
import {getWorkSpaceDirectory} from './config-variables'
|
||||
import {normalize, resolve} from 'path'
|
||||
|
||||
export interface ArtifactClient {
|
||||
/**
|
||||
* Uploads an artifact
|
||||
*
|
||||
* @param name the name of the artifact, required
|
||||
* @param files a list of absolute or relative paths that denote what files should be uploaded
|
||||
* @param rootDirectory an absolute or relative file path that denotes the root parent directory of the files being uploaded
|
||||
* @param options extra options for customizing the upload behavior
|
||||
* @returns single UploadInfo object
|
||||
*/
|
||||
uploadArtifact(
|
||||
name: string,
|
||||
files: string[],
|
||||
rootDirectory: string,
|
||||
options?: UploadOptions
|
||||
): Promise<UploadResponse>
|
||||
|
||||
/**
|
||||
* Downloads a single artifact associated with a run
|
||||
*
|
||||
* @param name the name of the artifact being downloaded
|
||||
* @param path optional path that denotes where the artifact will be downloaded to
|
||||
* @param options extra options that allow for the customization of the download behavior
|
||||
*/
|
||||
downloadArtifact(
|
||||
name: string,
|
||||
path?: string,
|
||||
options?: DownloadOptions
|
||||
): Promise<DownloadResponse>
|
||||
|
||||
/**
|
||||
* Downloads all artifacts associated with a run. Because there are multiple artifacts being downloaded, a folder will be created for each one in the specified or default directory
|
||||
* @param path optional path that denotes where the artifacts will be downloaded to
|
||||
*/
|
||||
downloadAllArtifacts(path?: string): Promise<DownloadResponse[]>
|
||||
}
|
||||
|
||||
export class DefaultArtifactClient implements ArtifactClient {
|
||||
/**
|
||||
* Constructs a DefaultArtifactClient
|
||||
*/
|
||||
static create(): DefaultArtifactClient {
|
||||
return new DefaultArtifactClient()
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an artifact
|
||||
*/
|
||||
async uploadArtifact(
|
||||
name: string,
|
||||
files: string[],
|
||||
rootDirectory: string,
|
||||
options?: UploadOptions | undefined
|
||||
): Promise<UploadResponse> {
|
||||
checkArtifactName(name)
|
||||
|
||||
// Get specification for the files being uploaded
|
||||
const uploadSpecification: UploadSpecification[] = getUploadSpecification(
|
||||
name,
|
||||
rootDirectory,
|
||||
files
|
||||
)
|
||||
const uploadResponse: UploadResponse = {
|
||||
artifactName: name,
|
||||
artifactItems: [],
|
||||
size: 0,
|
||||
failedItems: []
|
||||
}
|
||||
|
||||
const uploadHttpClient = new UploadHttpClient()
|
||||
|
||||
if (uploadSpecification.length === 0) {
|
||||
core.warning(`No files found that can be uploaded`)
|
||||
} else {
|
||||
// Create an entry for the artifact in the file container
|
||||
const response = await uploadHttpClient.createArtifactInFileContainer(
|
||||
name,
|
||||
options
|
||||
)
|
||||
if (!response.fileContainerResourceUrl) {
|
||||
core.debug(response.toString())
|
||||
throw new Error(
|
||||
'No URL provided by the Artifact Service to upload an artifact to'
|
||||
)
|
||||
}
|
||||
core.debug(`Upload Resource URL: ${response.fileContainerResourceUrl}`)
|
||||
|
||||
// Upload each of the files that were found concurrently
|
||||
const uploadResult = await uploadHttpClient.uploadArtifactToFileContainer(
|
||||
response.fileContainerResourceUrl,
|
||||
uploadSpecification,
|
||||
options
|
||||
)
|
||||
|
||||
// Update the size of the artifact to indicate we are done uploading
|
||||
// The uncompressed size is used for display when downloading a zip of the artifact from the UI
|
||||
await uploadHttpClient.patchArtifactSize(uploadResult.totalSize, name)
|
||||
|
||||
core.info(
|
||||
`Finished uploading artifact ${name}. Reported size is ${uploadResult.uploadSize} bytes. There were ${uploadResult.failedItems.length} items that failed to upload`
|
||||
)
|
||||
|
||||
uploadResponse.artifactItems = uploadSpecification.map(
|
||||
item => item.absoluteFilePath
|
||||
)
|
||||
uploadResponse.size = uploadResult.uploadSize
|
||||
uploadResponse.failedItems = uploadResult.failedItems
|
||||
}
|
||||
return uploadResponse
|
||||
}
|
||||
|
||||
async downloadArtifact(
|
||||
name: string,
|
||||
path?: string | undefined,
|
||||
options?: DownloadOptions | undefined
|
||||
): Promise<DownloadResponse> {
|
||||
const downloadHttpClient = new DownloadHttpClient()
|
||||
|
||||
const artifacts = await downloadHttpClient.listArtifacts()
|
||||
if (artifacts.count === 0) {
|
||||
throw new Error(
|
||||
`Unable to find any artifacts for the associated workflow`
|
||||
)
|
||||
}
|
||||
|
||||
const artifactToDownload = artifacts.value.find(artifact => {
|
||||
return artifact.name === name
|
||||
})
|
||||
if (!artifactToDownload) {
|
||||
throw new Error(`Unable to find an artifact with the name: ${name}`)
|
||||
}
|
||||
|
||||
const items = await downloadHttpClient.getContainerItems(
|
||||
artifactToDownload.name,
|
||||
artifactToDownload.fileContainerResourceUrl
|
||||
)
|
||||
|
||||
if (!path) {
|
||||
path = getWorkSpaceDirectory()
|
||||
}
|
||||
path = normalize(path)
|
||||
path = resolve(path)
|
||||
|
||||
// During upload, empty directories are rejected by the remote server so there should be no artifacts that consist of only empty directories
|
||||
const downloadSpecification = getDownloadSpecification(
|
||||
name,
|
||||
items.value,
|
||||
path,
|
||||
options?.createArtifactFolder || false
|
||||
)
|
||||
|
||||
if (downloadSpecification.filesToDownload.length === 0) {
|
||||
core.info(
|
||||
`No downloadable files were found for the artifact: ${artifactToDownload.name}`
|
||||
)
|
||||
} else {
|
||||
// Create all necessary directories recursively before starting any download
|
||||
await createDirectoriesForArtifact(
|
||||
downloadSpecification.directoryStructure
|
||||
)
|
||||
core.info('Directory structure has been setup for the artifact')
|
||||
await createEmptyFilesForArtifact(
|
||||
downloadSpecification.emptyFilesToCreate
|
||||
)
|
||||
await downloadHttpClient.downloadSingleArtifact(
|
||||
downloadSpecification.filesToDownload
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
artifactName: name,
|
||||
downloadPath: downloadSpecification.rootDownloadLocation
|
||||
}
|
||||
}
|
||||
|
||||
async downloadAllArtifacts(
|
||||
path?: string | undefined
|
||||
): Promise<DownloadResponse[]> {
|
||||
const downloadHttpClient = new DownloadHttpClient()
|
||||
|
||||
const response: DownloadResponse[] = []
|
||||
const artifacts = await downloadHttpClient.listArtifacts()
|
||||
if (artifacts.count === 0) {
|
||||
core.info('Unable to find any artifacts for the associated workflow')
|
||||
return response
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
path = getWorkSpaceDirectory()
|
||||
}
|
||||
path = normalize(path)
|
||||
path = resolve(path)
|
||||
|
||||
let downloadedArtifacts = 0
|
||||
while (downloadedArtifacts < artifacts.count) {
|
||||
const currentArtifactToDownload = artifacts.value[downloadedArtifacts]
|
||||
downloadedArtifacts += 1
|
||||
|
||||
// Get container entries for the specific artifact
|
||||
const items = await downloadHttpClient.getContainerItems(
|
||||
currentArtifactToDownload.name,
|
||||
currentArtifactToDownload.fileContainerResourceUrl
|
||||
)
|
||||
|
||||
const downloadSpecification = getDownloadSpecification(
|
||||
currentArtifactToDownload.name,
|
||||
items.value,
|
||||
path,
|
||||
true
|
||||
)
|
||||
if (downloadSpecification.filesToDownload.length === 0) {
|
||||
core.info(
|
||||
`No downloadable files were found for any artifact ${currentArtifactToDownload.name}`
|
||||
)
|
||||
} else {
|
||||
await createDirectoriesForArtifact(
|
||||
downloadSpecification.directoryStructure
|
||||
)
|
||||
await createEmptyFilesForArtifact(
|
||||
downloadSpecification.emptyFilesToCreate
|
||||
)
|
||||
await downloadHttpClient.downloadSingleArtifact(
|
||||
downloadSpecification.filesToDownload
|
||||
)
|
||||
}
|
||||
|
||||
response.push({
|
||||
artifactName: currentArtifactToDownload.name,
|
||||
downloadPath: downloadSpecification.rootDownloadLocation
|
||||
})
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {UploadOptions} from './upload/upload-options'
|
||||
import {UploadResponse} from './upload/upload-response'
|
||||
import {uploadArtifact} from './upload/upload-artifact'
|
||||
|
||||
export interface ArtifactClient {
|
||||
/**
|
||||
* Uploads an artifact
|
||||
*
|
||||
* @param name the name of the artifact, required
|
||||
* @param files a list of absolute or relative paths that denote what files should be uploaded
|
||||
* @param rootDirectory an absolute or relative file path that denotes the root parent directory of the files being uploaded
|
||||
* @param options extra options for customizing the upload behavior
|
||||
* @returns single UploadInfo object
|
||||
*/
|
||||
uploadArtifact(
|
||||
name: string,
|
||||
files: string[],
|
||||
rootDirectory: string,
|
||||
options?: UploadOptions
|
||||
): Promise<UploadResponse>
|
||||
|
||||
// TODO Download functionality
|
||||
}
|
||||
|
||||
export class Client implements ArtifactClient {
|
||||
/**
|
||||
* Constructs a Client
|
||||
*/
|
||||
static create(): Client {
|
||||
return new Client()
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an artifact
|
||||
*/
|
||||
async uploadArtifact(
|
||||
name: string,
|
||||
files: string[],
|
||||
rootDirectory: string,
|
||||
options?: UploadOptions | undefined
|
||||
): Promise<UploadResponse> {
|
||||
return uploadArtifact(name, files, rootDirectory, options)
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// The number of concurrent uploads that happens at the same time
|
||||
export function getUploadFileConcurrency(): number {
|
||||
return 2
|
||||
}
|
||||
|
||||
// When uploading large files that can't be uploaded with a single http call, this controls
|
||||
// the chunk size that is used during upload
|
||||
export function getUploadChunkSize(): number {
|
||||
return 8 * 1024 * 1024 // 8 MB Chunks
|
||||
}
|
||||
|
||||
// The maximum number of retries that can be attempted before an upload or download fails
|
||||
export function getRetryLimit(): number {
|
||||
return 5
|
||||
}
|
||||
|
||||
// With exponential backoff, the larger the retry count, the larger the wait time before another attempt
|
||||
// The retry multiplier controls by how much the backOff time increases depending on the number of retries
|
||||
export function getRetryMultiplier(): number {
|
||||
return 1.5
|
||||
}
|
||||
|
||||
// The initial wait time if an upload or download fails and a retry is being attempted for the first time
|
||||
export function getInitialRetryIntervalInMilliseconds(): number {
|
||||
return 3000
|
||||
}
|
||||
|
||||
// The number of concurrent downloads that happens at the same time
|
||||
export function getDownloadFileConcurrency(): number {
|
||||
return 2
|
||||
}
|
||||
|
||||
export function getRuntimeToken(): string {
|
||||
const token = process.env['ACTIONS_RUNTIME_TOKEN']
|
||||
if (!token) {
|
||||
throw new Error('Unable to get ACTIONS_RUNTIME_TOKEN env variable')
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
export function getRuntimeUrl(): string {
|
||||
const runtimeUrl = process.env['ACTIONS_RUNTIME_URL']
|
||||
if (!runtimeUrl) {
|
||||
throw new Error('Unable to get ACTIONS_RUNTIME_URL env variable')
|
||||
}
|
||||
return runtimeUrl
|
||||
}
|
||||
|
||||
export function getWorkFlowRunId(): string {
|
||||
const workFlowRunId = process.env['GITHUB_RUN_ID']
|
||||
if (!workFlowRunId) {
|
||||
throw new Error('Unable to get GITHUB_RUN_ID env variable')
|
||||
}
|
||||
return workFlowRunId
|
||||
}
|
||||
|
||||
export function getWorkSpaceDirectory(): string {
|
||||
const workspaceDirectory = process.env['GITHUB_WORKSPACE']
|
||||
if (!workspaceDirectory) {
|
||||
throw new Error('Unable to get GITHUB_WORKSPACE env variable')
|
||||
}
|
||||
return workspaceDirectory
|
||||
}
|
||||
|
||||
export function getRetentionDays(): string | undefined {
|
||||
return process.env['GITHUB_RETENTION_DAYS']
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
export interface ArtifactResponse {
|
||||
containerId: string
|
||||
size: number
|
||||
signedContent: string
|
||||
fileContainerResourceUrl: string
|
||||
type: string
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface CreateArtifactParameters {
|
||||
Type: string
|
||||
Name: string
|
||||
RetentionDays?: number
|
||||
}
|
||||
|
||||
export interface PatchArtifactSize {
|
||||
Size: number
|
||||
}
|
||||
|
||||
export interface PatchArtifactSizeSuccessResponse {
|
||||
containerId: number
|
||||
size: number
|
||||
signedContent: string
|
||||
type: string
|
||||
name: string
|
||||
url: string
|
||||
uploadUrl: string
|
||||
}
|
||||
|
||||
export interface UploadResults {
|
||||
uploadSize: number
|
||||
totalSize: number
|
||||
failedItems: string[]
|
||||
}
|
||||
|
||||
export interface ListArtifactsResponse {
|
||||
count: number
|
||||
value: ArtifactResponse[]
|
||||
}
|
||||
|
||||
export interface QueryArtifactResponse {
|
||||
count: number
|
||||
value: ContainerEntry[]
|
||||
}
|
||||
|
||||
export interface ContainerEntry {
|
||||
containerId: number
|
||||
scopeIdentifier: string
|
||||
path: string
|
||||
itemType: string
|
||||
status: string
|
||||
fileLength?: number
|
||||
fileEncoding?: number
|
||||
fileType?: number
|
||||
dateCreated: string
|
||||
dateLastModified: string
|
||||
createdBy: string
|
||||
lastModifiedBy: string
|
||||
itemLocation: string
|
||||
contentLocation: string
|
||||
fileId?: number
|
||||
contentId: string
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
import * as fs from 'fs'
|
||||
import * as core from '@actions/core'
|
||||
import * as zlib from 'zlib'
|
||||
import {
|
||||
getArtifactUrl,
|
||||
getDownloadHeaders,
|
||||
isSuccessStatusCode,
|
||||
isRetryableStatusCode,
|
||||
isThrottledStatusCode,
|
||||
getExponentialRetryTimeInMilliseconds,
|
||||
tryGetRetryAfterValueTimeInMilliseconds,
|
||||
displayHttpDiagnostics,
|
||||
getFileSize,
|
||||
rmFile,
|
||||
sleep
|
||||
} from './utils'
|
||||
import {URL} from 'url'
|
||||
import {StatusReporter} from './status-reporter'
|
||||
import {performance} from 'perf_hooks'
|
||||
import {ListArtifactsResponse, QueryArtifactResponse} from './contracts'
|
||||
import {IHttpClientResponse} from '@actions/http-client/interfaces'
|
||||
import {HttpManager} from './http-manager'
|
||||
import {DownloadItem} from './download-specification'
|
||||
import {getDownloadFileConcurrency, getRetryLimit} from './config-variables'
|
||||
import {IncomingHttpHeaders} from 'http'
|
||||
import {retryHttpClientRequest} from './requestUtils'
|
||||
|
||||
export class DownloadHttpClient {
|
||||
// http manager is used for concurrent connections when downloading multiple files at once
|
||||
private downloadHttpManager: HttpManager
|
||||
private statusReporter: StatusReporter
|
||||
|
||||
constructor() {
|
||||
this.downloadHttpManager = new HttpManager(
|
||||
getDownloadFileConcurrency(),
|
||||
'@actions/artifact-download'
|
||||
)
|
||||
// downloads are usually significantly faster than uploads so display status information every second
|
||||
this.statusReporter = new StatusReporter(1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of all artifacts that are in a specific container
|
||||
*/
|
||||
async listArtifacts(): Promise<ListArtifactsResponse> {
|
||||
const artifactUrl = getArtifactUrl()
|
||||
|
||||
// use the first client from the httpManager, `keep-alive` is not used so the connection will close immediately
|
||||
const client = this.downloadHttpManager.getClient(0)
|
||||
const headers = getDownloadHeaders('application/json')
|
||||
const response = await retryHttpClientRequest('List Artifacts', async () =>
|
||||
client.get(artifactUrl, headers)
|
||||
)
|
||||
const body: string = await response.readBody()
|
||||
return JSON.parse(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a set of container items that describe the contents of an artifact
|
||||
* @param artifactName the name of the artifact
|
||||
* @param containerUrl the artifact container URL for the run
|
||||
*/
|
||||
async getContainerItems(
|
||||
artifactName: string,
|
||||
containerUrl: string
|
||||
): Promise<QueryArtifactResponse> {
|
||||
// the itemPath search parameter controls which containers will be returned
|
||||
const resourceUrl = new URL(containerUrl)
|
||||
resourceUrl.searchParams.append('itemPath', artifactName)
|
||||
|
||||
// use the first client from the httpManager, `keep-alive` is not used so the connection will close immediately
|
||||
const client = this.downloadHttpManager.getClient(0)
|
||||
const headers = getDownloadHeaders('application/json')
|
||||
const response = await retryHttpClientRequest(
|
||||
'Get Container Items',
|
||||
async () => client.get(resourceUrl.toString(), headers)
|
||||
)
|
||||
const body: string = await response.readBody()
|
||||
return JSON.parse(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Concurrently downloads all the files that are part of an artifact
|
||||
* @param downloadItems information about what items to download and where to save them
|
||||
*/
|
||||
async downloadSingleArtifact(downloadItems: DownloadItem[]): Promise<void> {
|
||||
const DOWNLOAD_CONCURRENCY = getDownloadFileConcurrency()
|
||||
// limit the number of files downloaded at a single time
|
||||
core.debug(`Download file concurrency is set to ${DOWNLOAD_CONCURRENCY}`)
|
||||
const parallelDownloads = [...new Array(DOWNLOAD_CONCURRENCY).keys()]
|
||||
let currentFile = 0
|
||||
let downloadedFiles = 0
|
||||
|
||||
core.info(
|
||||
`Total number of files that will be downloaded: ${downloadItems.length}`
|
||||
)
|
||||
|
||||
this.statusReporter.setTotalNumberOfFilesToProcess(downloadItems.length)
|
||||
this.statusReporter.start()
|
||||
|
||||
await Promise.all(
|
||||
parallelDownloads.map(async index => {
|
||||
while (currentFile < downloadItems.length) {
|
||||
const currentFileToDownload = downloadItems[currentFile]
|
||||
currentFile += 1
|
||||
|
||||
const startTime = performance.now()
|
||||
await this.downloadIndividualFile(
|
||||
index,
|
||||
currentFileToDownload.sourceLocation,
|
||||
currentFileToDownload.targetPath
|
||||
)
|
||||
|
||||
if (core.isDebug()) {
|
||||
core.debug(
|
||||
`File: ${++downloadedFiles}/${downloadItems.length}. ${
|
||||
currentFileToDownload.targetPath
|
||||
} took ${(performance.now() - startTime).toFixed(
|
||||
3
|
||||
)} milliseconds to finish downloading`
|
||||
)
|
||||
}
|
||||
|
||||
this.statusReporter.incrementProcessedCount()
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
throw new Error(`Unable to download the artifact: ${error}`)
|
||||
})
|
||||
.finally(() => {
|
||||
this.statusReporter.stop()
|
||||
// safety dispose all connections
|
||||
this.downloadHttpManager.disposeAndReplaceAllClients()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an individual file
|
||||
* @param httpClientIndex the index of the http client that is used to make all of the calls
|
||||
* @param artifactLocation origin location where a file will be downloaded from
|
||||
* @param downloadPath destination location for the file being downloaded
|
||||
*/
|
||||
private async downloadIndividualFile(
|
||||
httpClientIndex: number,
|
||||
artifactLocation: string,
|
||||
downloadPath: string
|
||||
): Promise<void> {
|
||||
let retryCount = 0
|
||||
const retryLimit = getRetryLimit()
|
||||
let destinationStream = fs.createWriteStream(downloadPath)
|
||||
const headers = getDownloadHeaders('application/json', true, true)
|
||||
|
||||
// a single GET request is used to download a file
|
||||
const makeDownloadRequest = async (): Promise<IHttpClientResponse> => {
|
||||
const client = this.downloadHttpManager.getClient(httpClientIndex)
|
||||
return await client.get(artifactLocation, headers)
|
||||
}
|
||||
|
||||
// check the response headers to determine if the file was compressed using gzip
|
||||
const isGzip = (incomingHeaders: IncomingHttpHeaders): boolean => {
|
||||
return (
|
||||
'content-encoding' in incomingHeaders &&
|
||||
incomingHeaders['content-encoding'] === 'gzip'
|
||||
)
|
||||
}
|
||||
|
||||
// Increments the current retry count and then checks if the retry limit has been reached
|
||||
// If there have been too many retries, fail so the download stops. If there is a retryAfterValue value provided,
|
||||
// it will be used
|
||||
const backOff = async (retryAfterValue?: number): Promise<void> => {
|
||||
retryCount++
|
||||
if (retryCount > retryLimit) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`Retry limit has been reached. Unable to download ${artifactLocation}`
|
||||
)
|
||||
)
|
||||
} else {
|
||||
this.downloadHttpManager.disposeAndReplaceClient(httpClientIndex)
|
||||
if (retryAfterValue) {
|
||||
// Back off by waiting the specified time denoted by the retry-after header
|
||||
core.info(
|
||||
`Backoff due to too many requests, retry #${retryCount}. Waiting for ${retryAfterValue} milliseconds before continuing the download`
|
||||
)
|
||||
await sleep(retryAfterValue)
|
||||
} else {
|
||||
// Back off using an exponential value that depends on the retry count
|
||||
const backoffTime = getExponentialRetryTimeInMilliseconds(retryCount)
|
||||
core.info(
|
||||
`Exponential backoff for retry #${retryCount}. Waiting for ${backoffTime} milliseconds before continuing the download`
|
||||
)
|
||||
await sleep(backoffTime)
|
||||
}
|
||||
core.info(
|
||||
`Finished backoff for retry #${retryCount}, continuing with download`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const isAllBytesReceived = (
|
||||
expected?: string,
|
||||
received?: number
|
||||
): boolean => {
|
||||
// be lenient, if any input is missing, assume success, i.e. not truncated
|
||||
if (
|
||||
!expected ||
|
||||
!received ||
|
||||
process.env['ACTIONS_ARTIFACT_SKIP_DOWNLOAD_VALIDATION']
|
||||
) {
|
||||
core.info('Skipping download validation.')
|
||||
return true
|
||||
}
|
||||
|
||||
return parseInt(expected) === received
|
||||
}
|
||||
|
||||
const resetDestinationStream = async (
|
||||
fileDownloadPath: string
|
||||
): Promise<void> => {
|
||||
destinationStream.close()
|
||||
await rmFile(fileDownloadPath)
|
||||
destinationStream = fs.createWriteStream(fileDownloadPath)
|
||||
}
|
||||
|
||||
// keep trying to download a file until a retry limit has been reached
|
||||
while (retryCount <= retryLimit) {
|
||||
let response: IHttpClientResponse
|
||||
try {
|
||||
response = await makeDownloadRequest()
|
||||
if (core.isDebug()) {
|
||||
displayHttpDiagnostics(response)
|
||||
}
|
||||
} catch (error) {
|
||||
// if an error is caught, it is usually indicative of a timeout so retry the download
|
||||
core.info('An error occurred while attempting to download a file')
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(error)
|
||||
|
||||
// increment the retryCount and use exponential backoff to wait before making the next request
|
||||
await backOff()
|
||||
continue
|
||||
}
|
||||
|
||||
let forceRetry = false
|
||||
if (isSuccessStatusCode(response.message.statusCode)) {
|
||||
// The body contains the contents of the file however calling response.readBody() causes all the content to be converted to a string
|
||||
// which can cause some gzip encoded data to be lost
|
||||
// Instead of using response.readBody(), response.message is a readableStream that can be directly used to get the raw body contents
|
||||
try {
|
||||
const isGzipped = isGzip(response.message.headers)
|
||||
await this.pipeResponseToFile(response, destinationStream, isGzipped)
|
||||
|
||||
if (
|
||||
isGzipped ||
|
||||
isAllBytesReceived(
|
||||
response.message.headers['content-length'],
|
||||
await getFileSize(downloadPath)
|
||||
)
|
||||
) {
|
||||
return
|
||||
} else {
|
||||
forceRetry = true
|
||||
}
|
||||
} catch (error) {
|
||||
// retry on error, most likely streams were corrupted
|
||||
forceRetry = true
|
||||
}
|
||||
}
|
||||
|
||||
if (forceRetry || isRetryableStatusCode(response.message.statusCode)) {
|
||||
core.info(
|
||||
`A ${response.message.statusCode} response code has been received while attempting to download an artifact`
|
||||
)
|
||||
resetDestinationStream(downloadPath)
|
||||
// if a throttled status code is received, try to get the retryAfter header value, else differ to standard exponential backoff
|
||||
isThrottledStatusCode(response.message.statusCode)
|
||||
? await backOff(
|
||||
tryGetRetryAfterValueTimeInMilliseconds(response.message.headers)
|
||||
)
|
||||
: await backOff()
|
||||
} else {
|
||||
// Some unexpected response code, fail immediately and stop the download
|
||||
displayHttpDiagnostics(response)
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`Unexpected http ${response.message.statusCode} during download for ${artifactLocation}`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipes the response from downloading an individual file to the appropriate destination stream while decoding gzip content if necessary
|
||||
* @param response the http response received when downloading a file
|
||||
* @param destinationStream the stream where the file should be written to
|
||||
* @param isGzip a boolean denoting if the content is compressed using gzip and if we need to decode it
|
||||
*/
|
||||
async pipeResponseToFile(
|
||||
response: IHttpClientResponse,
|
||||
destinationStream: fs.WriteStream,
|
||||
isGzip: boolean
|
||||
): Promise<void> {
|
||||
await new Promise((resolve, reject) => {
|
||||
if (isGzip) {
|
||||
const gunzip = zlib.createGunzip()
|
||||
response.message
|
||||
.on('error', error => {
|
||||
core.error(
|
||||
`An error occurred while attempting to read the response stream`
|
||||
)
|
||||
gunzip.close()
|
||||
destinationStream.close()
|
||||
reject(error)
|
||||
})
|
||||
.pipe(gunzip)
|
||||
.on('error', error => {
|
||||
core.error(
|
||||
`An error occurred while attempting to decompress the response stream`
|
||||
)
|
||||
destinationStream.close()
|
||||
reject(error)
|
||||
})
|
||||
.pipe(destinationStream)
|
||||
.on('close', () => {
|
||||
resolve()
|
||||
})
|
||||
.on('error', error => {
|
||||
core.error(
|
||||
`An error occurred while writing a downloaded file to ${destinationStream.path}`
|
||||
)
|
||||
reject(error)
|
||||
})
|
||||
} else {
|
||||
response.message
|
||||
.on('error', error => {
|
||||
core.error(
|
||||
`An error occurred while attempting to read the response stream`
|
||||
)
|
||||
destinationStream.close()
|
||||
reject(error)
|
||||
})
|
||||
.pipe(destinationStream)
|
||||
.on('close', () => {
|
||||
resolve()
|
||||
})
|
||||
.on('error', error => {
|
||||
core.error(
|
||||
`An error occurred while writing a downloaded file to ${destinationStream.path}`
|
||||
)
|
||||
reject(error)
|
||||
})
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface DownloadOptions {
|
||||
/**
|
||||
* Specifies if a folder is created for the artifact that is downloaded (contents downloaded into this folder),
|
||||
* defaults to false if not specified
|
||||
* */
|
||||
createArtifactFolder?: boolean
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export interface DownloadResponse {
|
||||
/**
|
||||
* The name of the artifact that was downloaded
|
||||
*/
|
||||
artifactName: string
|
||||
|
||||
/**
|
||||
* The full Path to where the artifact was downloaded
|
||||
*/
|
||||
downloadPath: string
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import * as path from 'path'
|
||||
import {ContainerEntry} from './contracts'
|
||||
|
||||
export interface DownloadSpecification {
|
||||
// root download location for the artifact
|
||||
rootDownloadLocation: string
|
||||
|
||||
// directories that need to be created for all the items in the artifact
|
||||
directoryStructure: string[]
|
||||
|
||||
// empty files that are part of the artifact that don't require any downloading
|
||||
emptyFilesToCreate: string[]
|
||||
|
||||
// individual files that need to be downloaded as part of the artifact
|
||||
filesToDownload: DownloadItem[]
|
||||
}
|
||||
|
||||
export interface DownloadItem {
|
||||
// Url that denotes where to download the item from
|
||||
sourceLocation: string
|
||||
|
||||
// Information about where the file should be downloaded to
|
||||
targetPath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a specification for a set of files that will be downloaded
|
||||
* @param artifactName the name of the artifact
|
||||
* @param artifactEntries a set of container entries that describe that files that make up an artifact
|
||||
* @param downloadPath the path where the artifact will be downloaded to
|
||||
* @param includeRootDirectory specifies if there should be an extra directory (denoted by the artifact name) where the artifact files should be downloaded to
|
||||
*/
|
||||
export function getDownloadSpecification(
|
||||
artifactName: string,
|
||||
artifactEntries: ContainerEntry[],
|
||||
downloadPath: string,
|
||||
includeRootDirectory: boolean
|
||||
): DownloadSpecification {
|
||||
// use a set for the directory paths so that there are no duplicates
|
||||
const directories = new Set<string>()
|
||||
|
||||
const specifications: DownloadSpecification = {
|
||||
rootDownloadLocation: includeRootDirectory
|
||||
? path.join(downloadPath, artifactName)
|
||||
: downloadPath,
|
||||
directoryStructure: [],
|
||||
emptyFilesToCreate: [],
|
||||
filesToDownload: []
|
||||
}
|
||||
|
||||
for (const entry of artifactEntries) {
|
||||
// Ignore artifacts in the container that don't begin with the same name
|
||||
if (
|
||||
entry.path.startsWith(`${artifactName}/`) ||
|
||||
entry.path.startsWith(`${artifactName}\\`)
|
||||
) {
|
||||
// normalize all separators to the local OS
|
||||
const normalizedPathEntry = path.normalize(entry.path)
|
||||
// entry.path always starts with the artifact name, if includeRootDirectory is false, remove the name from the beginning of the path
|
||||
const filePath = path.join(
|
||||
downloadPath,
|
||||
includeRootDirectory
|
||||
? normalizedPathEntry
|
||||
: normalizedPathEntry.replace(artifactName, '')
|
||||
)
|
||||
|
||||
// Case insensitive folder structure maintained in the backend, not every folder is created so the 'folder'
|
||||
// itemType cannot be relied upon. The file must be used to determine the directory structure
|
||||
if (entry.itemType === 'file') {
|
||||
// Get the directories that we need to create from the filePath for each individual file
|
||||
directories.add(path.dirname(filePath))
|
||||
if (entry.fileLength === 0) {
|
||||
// An empty file was uploaded, create the empty files locally so that no extra http calls are made
|
||||
specifications.emptyFilesToCreate.push(filePath)
|
||||
} else {
|
||||
specifications.filesToDownload.push({
|
||||
sourceLocation: entry.contentLocation,
|
||||
targetPath: filePath
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
specifications.directoryStructure = Array.from(directories)
|
||||
return specifications
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import {HttpClient} from '@actions/http-client/index'
|
||||
import {createHttpClient} from './utils'
|
||||
|
||||
/**
|
||||
* Used for managing http clients during either upload or download
|
||||
*/
|
||||
export class HttpManager {
|
||||
private clients: HttpClient[]
|
||||
private userAgent: string
|
||||
|
||||
constructor(clientCount: number, userAgent: string) {
|
||||
if (clientCount < 1) {
|
||||
throw new Error('There must be at least one client')
|
||||
}
|
||||
this.userAgent = userAgent
|
||||
this.clients = new Array(clientCount).fill(createHttpClient(userAgent))
|
||||
}
|
||||
|
||||
getClient(index: number): HttpClient {
|
||||
return this.clients[index]
|
||||
}
|
||||
|
||||
// client disposal is necessary if a keep-alive connection is used to properly close the connection
|
||||
// for more information see: https://github.com/actions/http-client/blob/04e5ad73cd3fd1f5610a32116b0759eddf6570d2/index.ts#L292
|
||||
disposeAndReplaceClient(index: number): void {
|
||||
this.clients[index].dispose()
|
||||
this.clients[index] = createHttpClient(this.userAgent)
|
||||
}
|
||||
|
||||
disposeAndReplaceAllClients(): void {
|
||||
for (const [index] of this.clients.entries()) {
|
||||
this.disposeAndReplaceClient(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import {IHttpClientResponse} from '@actions/http-client/interfaces'
|
||||
import {
|
||||
isRetryableStatusCode,
|
||||
isSuccessStatusCode,
|
||||
sleep,
|
||||
getExponentialRetryTimeInMilliseconds,
|
||||
displayHttpDiagnostics
|
||||
} from './utils'
|
||||
import * as core from '@actions/core'
|
||||
import {getRetryLimit} from './config-variables'
|
||||
|
||||
export async function retry(
|
||||
name: string,
|
||||
operation: () => Promise<IHttpClientResponse>,
|
||||
customErrorMessages: Map<number, string>,
|
||||
maxAttempts: number
|
||||
): Promise<IHttpClientResponse> {
|
||||
let response: IHttpClientResponse | undefined = undefined
|
||||
let statusCode: number | undefined = undefined
|
||||
let isRetryable = false
|
||||
let errorMessage = ''
|
||||
let customErrorInformation: string | undefined = undefined
|
||||
let attempt = 1
|
||||
|
||||
while (attempt <= maxAttempts) {
|
||||
try {
|
||||
response = await operation()
|
||||
statusCode = response.message.statusCode
|
||||
|
||||
if (isSuccessStatusCode(statusCode)) {
|
||||
return response
|
||||
}
|
||||
|
||||
// Extra error information that we want to display if a particular response code is hit
|
||||
if (statusCode) {
|
||||
customErrorInformation = customErrorMessages.get(statusCode)
|
||||
}
|
||||
|
||||
isRetryable = isRetryableStatusCode(statusCode)
|
||||
errorMessage = `Artifact service responded with ${statusCode}`
|
||||
} catch (error) {
|
||||
isRetryable = true
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
if (!isRetryable) {
|
||||
core.info(`${name} - Error is not retryable`)
|
||||
if (response) {
|
||||
displayHttpDiagnostics(response)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
core.info(
|
||||
`${name} - Attempt ${attempt} of ${maxAttempts} failed with error: ${errorMessage}`
|
||||
)
|
||||
|
||||
await sleep(getExponentialRetryTimeInMilliseconds(attempt))
|
||||
attempt++
|
||||
}
|
||||
|
||||
if (response) {
|
||||
displayHttpDiagnostics(response)
|
||||
}
|
||||
|
||||
if (customErrorInformation) {
|
||||
throw Error(`${name} failed: ${customErrorInformation}`)
|
||||
}
|
||||
throw Error(`${name} failed: ${errorMessage}`)
|
||||
}
|
||||
|
||||
export async function retryHttpClientRequest<T>(
|
||||
name: string,
|
||||
method: () => Promise<IHttpClientResponse>,
|
||||
customErrorMessages: Map<number, string> = new Map(),
|
||||
maxAttempts = getRetryLimit()
|
||||
): Promise<IHttpClientResponse> {
|
||||
return await retry(name, method, customErrorMessages, maxAttempts)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import {info} from '@actions/core'
|
||||
|
||||
/**
|
||||
* Status Reporter that displays information about the progress/status of an artifact that is being uploaded or downloaded
|
||||
*
|
||||
* Variable display time that can be adjusted using the displayFrequencyInMilliseconds variable
|
||||
* The total status of the upload/download gets displayed according to this value
|
||||
* If there is a large file that is being uploaded, extra information about the individual status can also be displayed using the updateLargeFileStatus function
|
||||
*/
|
||||
|
||||
export class StatusReporter {
|
||||
private totalNumberOfFilesToProcess = 0
|
||||
private processedCount = 0
|
||||
private displayFrequencyInMilliseconds: number
|
||||
private largeFiles = new Map<string, string>()
|
||||
private totalFileStatus: NodeJS.Timeout | undefined
|
||||
private largeFileStatus: NodeJS.Timeout | undefined
|
||||
|
||||
constructor(displayFrequencyInMilliseconds: number) {
|
||||
this.totalFileStatus = undefined
|
||||
this.largeFileStatus = undefined
|
||||
this.displayFrequencyInMilliseconds = displayFrequencyInMilliseconds
|
||||
}
|
||||
|
||||
setTotalNumberOfFilesToProcess(fileTotal: number): void {
|
||||
this.totalNumberOfFilesToProcess = fileTotal
|
||||
}
|
||||
|
||||
start(): void {
|
||||
// displays information about the total upload/download status
|
||||
this.totalFileStatus = setInterval(() => {
|
||||
// display 1 decimal place without any rounding
|
||||
const percentage = this.formatPercentage(
|
||||
this.processedCount,
|
||||
this.totalNumberOfFilesToProcess
|
||||
)
|
||||
info(
|
||||
`Total file count: ${
|
||||
this.totalNumberOfFilesToProcess
|
||||
} ---- Processed file #${this.processedCount} (${percentage.slice(
|
||||
0,
|
||||
percentage.indexOf('.') + 2
|
||||
)}%)`
|
||||
)
|
||||
}, this.displayFrequencyInMilliseconds)
|
||||
|
||||
// displays extra information about any large files that take a significant amount of time to upload or download every 1 second
|
||||
this.largeFileStatus = setInterval(() => {
|
||||
for (const value of Array.from(this.largeFiles.values())) {
|
||||
info(value)
|
||||
}
|
||||
// delete all entries in the map after displaying the information so it will not be displayed again unless explicitly added
|
||||
this.largeFiles.clear()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// if there is a large file that is being uploaded in chunks, this is used to display extra information about the status of the upload
|
||||
updateLargeFileStatus(
|
||||
fileName: string,
|
||||
numerator: number,
|
||||
denominator: number
|
||||
): void {
|
||||
// display 1 decimal place without any rounding
|
||||
const percentage = this.formatPercentage(numerator, denominator)
|
||||
const displayInformation = `Uploading ${fileName} (${percentage.slice(
|
||||
0,
|
||||
percentage.indexOf('.') + 2
|
||||
)}%)`
|
||||
|
||||
// any previously added display information should be overwritten for the specific large file because a map is being used
|
||||
this.largeFiles.set(fileName, displayInformation)
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.totalFileStatus) {
|
||||
clearInterval(this.totalFileStatus)
|
||||
}
|
||||
|
||||
if (this.largeFileStatus) {
|
||||
clearInterval(this.largeFileStatus)
|
||||
}
|
||||
}
|
||||
|
||||
incrementProcessedCount(): void {
|
||||
this.processedCount++
|
||||
}
|
||||
|
||||
private formatPercentage(numerator: number, denominator: number): string {
|
||||
// toFixed() rounds, so use extra precision to display accurate information even though 4 decimal places are not displayed
|
||||
return ((numerator / denominator) * 100).toFixed(4).toString()
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import * as fs from 'fs'
|
||||
import * as zlib from 'zlib'
|
||||
import {promisify} from 'util'
|
||||
const stat = promisify(fs.stat)
|
||||
|
||||
/**
|
||||
* Creates a Gzip compressed file of an original file at the provided temporary filepath location
|
||||
* @param {string} originalFilePath filepath of whatever will be compressed. The original file will be unmodified
|
||||
* @param {string} tempFilePath the location of where the Gzip file will be created
|
||||
* @returns the size of gzip file that gets created
|
||||
*/
|
||||
export async function createGZipFileOnDisk(
|
||||
originalFilePath: string,
|
||||
tempFilePath: string
|
||||
): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const inputStream = fs.createReadStream(originalFilePath)
|
||||
const gzip = zlib.createGzip()
|
||||
const outputStream = fs.createWriteStream(tempFilePath)
|
||||
inputStream.pipe(gzip).pipe(outputStream)
|
||||
outputStream.on('finish', async () => {
|
||||
// wait for stream to finish before calculating the size which is needed as part of the Content-Length header when starting an upload
|
||||
const size = (await stat(tempFilePath)).size
|
||||
resolve(size)
|
||||
})
|
||||
outputStream.on('error', error => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(error)
|
||||
reject
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a GZip file in memory using a buffer. Should be used for smaller files to reduce disk I/O
|
||||
* @param originalFilePath the path to the original file that is being GZipped
|
||||
* @returns a buffer with the GZip file
|
||||
*/
|
||||
export async function createGZipFileInBuffer(
|
||||
originalFilePath: string
|
||||
): Promise<Buffer> {
|
||||
return new Promise(async resolve => {
|
||||
const inputStream = fs.createReadStream(originalFilePath)
|
||||
const gzip = zlib.createGzip()
|
||||
inputStream.pipe(gzip)
|
||||
// read stream into buffer, using experimental async iterators see https://github.com/nodejs/readable-stream/issues/403#issuecomment-479069043
|
||||
const chunks = []
|
||||
for await (const chunk of gzip) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
resolve(Buffer.concat(chunks))
|
||||
})
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
import * as fs from 'fs'
|
||||
import * as core from '@actions/core'
|
||||
import * as tmp from 'tmp-promise'
|
||||
import * as stream from 'stream'
|
||||
import {
|
||||
ArtifactResponse,
|
||||
CreateArtifactParameters,
|
||||
PatchArtifactSize,
|
||||
UploadResults
|
||||
} from './contracts'
|
||||
import {
|
||||
getArtifactUrl,
|
||||
getContentRange,
|
||||
getUploadHeaders,
|
||||
isRetryableStatusCode,
|
||||
isSuccessStatusCode,
|
||||
isThrottledStatusCode,
|
||||
displayHttpDiagnostics,
|
||||
getExponentialRetryTimeInMilliseconds,
|
||||
tryGetRetryAfterValueTimeInMilliseconds,
|
||||
getProperRetention,
|
||||
sleep
|
||||
} from './utils'
|
||||
import {
|
||||
getUploadChunkSize,
|
||||
getUploadFileConcurrency,
|
||||
getRetryLimit,
|
||||
getRetentionDays
|
||||
} from './config-variables'
|
||||
import {promisify} from 'util'
|
||||
import {URL} from 'url'
|
||||
import {performance} from 'perf_hooks'
|
||||
import {StatusReporter} from './status-reporter'
|
||||
import {HttpCodes} from '@actions/http-client'
|
||||
import {IHttpClientResponse} from '@actions/http-client/interfaces'
|
||||
import {HttpManager} from './http-manager'
|
||||
import {UploadSpecification} from './upload-specification'
|
||||
import {UploadOptions} from './upload-options'
|
||||
import {createGZipFileOnDisk, createGZipFileInBuffer} from './upload-gzip'
|
||||
import {retryHttpClientRequest} from './requestUtils'
|
||||
const stat = promisify(fs.stat)
|
||||
|
||||
export class UploadHttpClient {
|
||||
private uploadHttpManager: HttpManager
|
||||
private statusReporter: StatusReporter
|
||||
|
||||
constructor() {
|
||||
this.uploadHttpManager = new HttpManager(
|
||||
getUploadFileConcurrency(),
|
||||
'@actions/artifact-upload'
|
||||
)
|
||||
this.statusReporter = new StatusReporter(10000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a file container for the new artifact in the remote blob storage/file service
|
||||
* @param {string} artifactName Name of the artifact being created
|
||||
* @returns The response from the Artifact Service if the file container was successfully created
|
||||
*/
|
||||
async createArtifactInFileContainer(
|
||||
artifactName: string,
|
||||
options?: UploadOptions | undefined
|
||||
): Promise<ArtifactResponse> {
|
||||
const parameters: CreateArtifactParameters = {
|
||||
Type: 'actions_storage',
|
||||
Name: artifactName
|
||||
}
|
||||
|
||||
// calculate retention period
|
||||
if (options && options.retentionDays) {
|
||||
const maxRetentionStr = getRetentionDays()
|
||||
parameters.RetentionDays = getProperRetention(
|
||||
options.retentionDays,
|
||||
maxRetentionStr
|
||||
)
|
||||
}
|
||||
|
||||
const data: string = JSON.stringify(parameters, null, 2)
|
||||
const artifactUrl = getArtifactUrl()
|
||||
|
||||
// use the first client from the httpManager, `keep-alive` is not used so the connection will close immediately
|
||||
const client = this.uploadHttpManager.getClient(0)
|
||||
const headers = getUploadHeaders('application/json', false)
|
||||
|
||||
// Extra information to display when a particular HTTP code is returned
|
||||
// If a 403 is returned when trying to create a file container, the customer has exceeded
|
||||
// their storage quota so no new artifact containers can be created
|
||||
const customErrorMessages: Map<number, string> = new Map([
|
||||
[
|
||||
HttpCodes.Forbidden,
|
||||
'Artifact storage quota has been hit. Unable to upload any new artifacts'
|
||||
],
|
||||
[
|
||||
HttpCodes.BadRequest,
|
||||
`The artifact name ${artifactName} is not valid. Request URL ${artifactUrl}`
|
||||
]
|
||||
])
|
||||
|
||||
const response = await retryHttpClientRequest(
|
||||
'Create Artifact Container',
|
||||
async () => client.post(artifactUrl, data, headers),
|
||||
customErrorMessages
|
||||
)
|
||||
const body: string = await response.readBody()
|
||||
return JSON.parse(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Concurrently upload all of the files in chunks
|
||||
* @param {string} uploadUrl Base Url for the artifact that was created
|
||||
* @param {SearchResult[]} filesToUpload A list of information about the files being uploaded
|
||||
* @returns The size of all the files uploaded in bytes
|
||||
*/
|
||||
async uploadArtifactToFileContainer(
|
||||
uploadUrl: string,
|
||||
filesToUpload: UploadSpecification[],
|
||||
options?: UploadOptions
|
||||
): Promise<UploadResults> {
|
||||
const FILE_CONCURRENCY = getUploadFileConcurrency()
|
||||
const MAX_CHUNK_SIZE = getUploadChunkSize()
|
||||
core.debug(
|
||||
`File Concurrency: ${FILE_CONCURRENCY}, and Chunk Size: ${MAX_CHUNK_SIZE}`
|
||||
)
|
||||
|
||||
const parameters: UploadFileParameters[] = []
|
||||
// by default, file uploads will continue if there is an error unless specified differently in the options
|
||||
let continueOnError = true
|
||||
if (options) {
|
||||
if (options.continueOnError === false) {
|
||||
continueOnError = false
|
||||
}
|
||||
}
|
||||
|
||||
// prepare the necessary parameters to upload all the files
|
||||
for (const file of filesToUpload) {
|
||||
const resourceUrl = new URL(uploadUrl)
|
||||
resourceUrl.searchParams.append('itemPath', file.uploadFilePath)
|
||||
parameters.push({
|
||||
file: file.absoluteFilePath,
|
||||
resourceUrl: resourceUrl.toString(),
|
||||
maxChunkSize: MAX_CHUNK_SIZE,
|
||||
continueOnError
|
||||
})
|
||||
}
|
||||
|
||||
const parallelUploads = [...new Array(FILE_CONCURRENCY).keys()]
|
||||
const failedItemsToReport: string[] = []
|
||||
let currentFile = 0
|
||||
let completedFiles = 0
|
||||
let uploadFileSize = 0
|
||||
let totalFileSize = 0
|
||||
let abortPendingFileUploads = false
|
||||
|
||||
this.statusReporter.setTotalNumberOfFilesToProcess(filesToUpload.length)
|
||||
this.statusReporter.start()
|
||||
|
||||
// only allow a certain amount of files to be uploaded at once, this is done to reduce potential errors
|
||||
await Promise.all(
|
||||
parallelUploads.map(async index => {
|
||||
while (currentFile < filesToUpload.length) {
|
||||
const currentFileParameters = parameters[currentFile]
|
||||
currentFile += 1
|
||||
if (abortPendingFileUploads) {
|
||||
failedItemsToReport.push(currentFileParameters.file)
|
||||
continue
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
const uploadFileResult = await this.uploadFileAsync(
|
||||
index,
|
||||
currentFileParameters
|
||||
)
|
||||
|
||||
if (core.isDebug()) {
|
||||
core.debug(
|
||||
`File: ${++completedFiles}/${filesToUpload.length}. ${
|
||||
currentFileParameters.file
|
||||
} took ${(performance.now() - startTime).toFixed(
|
||||
3
|
||||
)} milliseconds to finish upload`
|
||||
)
|
||||
}
|
||||
|
||||
uploadFileSize += uploadFileResult.successfulUploadSize
|
||||
totalFileSize += uploadFileResult.totalSize
|
||||
if (uploadFileResult.isSuccess === false) {
|
||||
failedItemsToReport.push(currentFileParameters.file)
|
||||
if (!continueOnError) {
|
||||
// fail fast
|
||||
core.error(`aborting artifact upload`)
|
||||
abortPendingFileUploads = true
|
||||
}
|
||||
}
|
||||
this.statusReporter.incrementProcessedCount()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
this.statusReporter.stop()
|
||||
// done uploading, safety dispose all connections
|
||||
this.uploadHttpManager.disposeAndReplaceAllClients()
|
||||
|
||||
core.info(`Total size of all the files uploaded is ${uploadFileSize} bytes`)
|
||||
return {
|
||||
uploadSize: uploadFileSize,
|
||||
totalSize: totalFileSize,
|
||||
failedItems: failedItemsToReport
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously uploads a file. The file is compressed and uploaded using GZip if it is determined to save space.
|
||||
* If the upload file is bigger than the max chunk size it will be uploaded via multiple calls
|
||||
* @param {number} httpClientIndex The index of the httpClient that is being used to make all of the calls
|
||||
* @param {UploadFileParameters} parameters Information about the file that needs to be uploaded
|
||||
* @returns The size of the file that was uploaded in bytes along with any failed uploads
|
||||
*/
|
||||
private async uploadFileAsync(
|
||||
httpClientIndex: number,
|
||||
parameters: UploadFileParameters
|
||||
): Promise<UploadFileResult> {
|
||||
const totalFileSize: number = (await stat(parameters.file)).size
|
||||
let offset = 0
|
||||
let isUploadSuccessful = true
|
||||
let failedChunkSizes = 0
|
||||
let uploadFileSize = 0
|
||||
let isGzip = true
|
||||
|
||||
// the file that is being uploaded is less than 64k in size, to increase throughput and to minimize disk I/O
|
||||
// for creating a new GZip file, an in-memory buffer is used for compression
|
||||
if (totalFileSize < 65536) {
|
||||
const buffer = await createGZipFileInBuffer(parameters.file)
|
||||
|
||||
//An open stream is needed in the event of a failure and we need to retry. If a NodeJS.ReadableStream is directly passed in,
|
||||
// it will not properly get reset to the start of the stream if a chunk upload needs to be retried
|
||||
let openUploadStream: () => NodeJS.ReadableStream
|
||||
|
||||
if (totalFileSize < buffer.byteLength) {
|
||||
// compression did not help with reducing the size, use a readable stream from the original file for upload
|
||||
openUploadStream = () => fs.createReadStream(parameters.file)
|
||||
isGzip = false
|
||||
uploadFileSize = totalFileSize
|
||||
} else {
|
||||
// create a readable stream using a PassThrough stream that is both readable and writable
|
||||
openUploadStream = () => {
|
||||
const passThrough = new stream.PassThrough()
|
||||
passThrough.end(buffer)
|
||||
return passThrough
|
||||
}
|
||||
uploadFileSize = buffer.byteLength
|
||||
}
|
||||
|
||||
const result = await this.uploadChunk(
|
||||
httpClientIndex,
|
||||
parameters.resourceUrl,
|
||||
openUploadStream,
|
||||
0,
|
||||
uploadFileSize - 1,
|
||||
uploadFileSize,
|
||||
isGzip,
|
||||
totalFileSize
|
||||
)
|
||||
|
||||
if (!result) {
|
||||
// chunk failed to upload
|
||||
isUploadSuccessful = false
|
||||
failedChunkSizes += uploadFileSize
|
||||
core.warning(`Aborting upload for ${parameters.file} due to failure`)
|
||||
}
|
||||
|
||||
return {
|
||||
isSuccess: isUploadSuccessful,
|
||||
successfulUploadSize: uploadFileSize - failedChunkSizes,
|
||||
totalSize: totalFileSize
|
||||
}
|
||||
} else {
|
||||
// the file that is being uploaded is greater than 64k in size, a temporary file gets created on disk using the
|
||||
// npm tmp-promise package and this file gets used to create a GZipped file
|
||||
const tempFile = await tmp.file()
|
||||
|
||||
// create a GZip file of the original file being uploaded, the original file should not be modified in any way
|
||||
uploadFileSize = await createGZipFileOnDisk(
|
||||
parameters.file,
|
||||
tempFile.path
|
||||
)
|
||||
|
||||
let uploadFilePath = tempFile.path
|
||||
|
||||
// compression did not help with size reduction, use the original file for upload and delete the temp GZip file
|
||||
if (totalFileSize < uploadFileSize) {
|
||||
uploadFileSize = totalFileSize
|
||||
uploadFilePath = parameters.file
|
||||
isGzip = false
|
||||
}
|
||||
|
||||
let abortFileUpload = false
|
||||
// upload only a single chunk at a time
|
||||
while (offset < uploadFileSize) {
|
||||
const chunkSize = Math.min(
|
||||
uploadFileSize - offset,
|
||||
parameters.maxChunkSize
|
||||
)
|
||||
|
||||
// if an individual file is greater than 100MB (1024*1024*100) in size, display extra information about the upload status
|
||||
if (uploadFileSize > 104857600) {
|
||||
this.statusReporter.updateLargeFileStatus(
|
||||
parameters.file,
|
||||
offset,
|
||||
uploadFileSize
|
||||
)
|
||||
}
|
||||
|
||||
const start = offset
|
||||
const end = offset + chunkSize - 1
|
||||
offset += parameters.maxChunkSize
|
||||
|
||||
if (abortFileUpload) {
|
||||
// if we don't want to continue in the event of an error, any pending upload chunks will be marked as failed
|
||||
failedChunkSizes += chunkSize
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await this.uploadChunk(
|
||||
httpClientIndex,
|
||||
parameters.resourceUrl,
|
||||
() =>
|
||||
fs.createReadStream(uploadFilePath, {
|
||||
start,
|
||||
end,
|
||||
autoClose: false
|
||||
}),
|
||||
start,
|
||||
end,
|
||||
uploadFileSize,
|
||||
isGzip,
|
||||
totalFileSize
|
||||
)
|
||||
|
||||
if (!result) {
|
||||
// Chunk failed to upload, report as failed and do not continue uploading any more chunks for the file. It is possible that part of a chunk was
|
||||
// successfully uploaded so the server may report a different size for what was uploaded
|
||||
isUploadSuccessful = false
|
||||
failedChunkSizes += chunkSize
|
||||
core.warning(`Aborting upload for ${parameters.file} due to failure`)
|
||||
abortFileUpload = true
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the temporary file that was created as part of the upload. If the temp file does not get manually deleted by
|
||||
// calling cleanup, it gets removed when the node process exits. For more info see: https://www.npmjs.com/package/tmp-promise#about
|
||||
await tempFile.cleanup()
|
||||
|
||||
return {
|
||||
isSuccess: isUploadSuccessful,
|
||||
successfulUploadSize: uploadFileSize - failedChunkSizes,
|
||||
totalSize: totalFileSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a chunk of an individual file to the specified resourceUrl. If the upload fails and the status code
|
||||
* indicates a retryable status, we try to upload the chunk as well
|
||||
* @param {number} httpClientIndex The index of the httpClient being used to make all the necessary calls
|
||||
* @param {string} resourceUrl Url of the resource that the chunk will be uploaded to
|
||||
* @param {NodeJS.ReadableStream} openStream Stream of the file that will be uploaded
|
||||
* @param {number} start Starting byte index of file that the chunk belongs to
|
||||
* @param {number} end Ending byte index of file that the chunk belongs to
|
||||
* @param {number} uploadFileSize Total size of the file in bytes that is being uploaded
|
||||
* @param {boolean} isGzip Denotes if we are uploading a Gzip compressed stream
|
||||
* @param {number} totalFileSize Original total size of the file that is being uploaded
|
||||
* @returns if the chunk was successfully uploaded
|
||||
*/
|
||||
private async uploadChunk(
|
||||
httpClientIndex: number,
|
||||
resourceUrl: string,
|
||||
openStream: () => NodeJS.ReadableStream,
|
||||
start: number,
|
||||
end: number,
|
||||
uploadFileSize: number,
|
||||
isGzip: boolean,
|
||||
totalFileSize: number
|
||||
): Promise<boolean> {
|
||||
// prepare all the necessary headers before making any http call
|
||||
const headers = getUploadHeaders(
|
||||
'application/octet-stream',
|
||||
true,
|
||||
isGzip,
|
||||
totalFileSize,
|
||||
end - start + 1,
|
||||
getContentRange(start, end, uploadFileSize)
|
||||
)
|
||||
|
||||
const uploadChunkRequest = async (): Promise<IHttpClientResponse> => {
|
||||
const client = this.uploadHttpManager.getClient(httpClientIndex)
|
||||
return await client.sendStream('PUT', resourceUrl, openStream(), headers)
|
||||
}
|
||||
|
||||
let retryCount = 0
|
||||
const retryLimit = getRetryLimit()
|
||||
|
||||
// Increments the current retry count and then checks if the retry limit has been reached
|
||||
// If there have been too many retries, fail so the download stops
|
||||
const incrementAndCheckRetryLimit = (
|
||||
response?: IHttpClientResponse
|
||||
): boolean => {
|
||||
retryCount++
|
||||
if (retryCount > retryLimit) {
|
||||
if (response) {
|
||||
displayHttpDiagnostics(response)
|
||||
}
|
||||
core.info(
|
||||
`Retry limit has been reached for chunk at offset ${start} to ${resourceUrl}`
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const backOff = async (retryAfterValue?: number): Promise<void> => {
|
||||
this.uploadHttpManager.disposeAndReplaceClient(httpClientIndex)
|
||||
if (retryAfterValue) {
|
||||
core.info(
|
||||
`Backoff due to too many requests, retry #${retryCount}. Waiting for ${retryAfterValue} milliseconds before continuing the upload`
|
||||
)
|
||||
await sleep(retryAfterValue)
|
||||
} else {
|
||||
const backoffTime = getExponentialRetryTimeInMilliseconds(retryCount)
|
||||
core.info(
|
||||
`Exponential backoff for retry #${retryCount}. Waiting for ${backoffTime} milliseconds before continuing the upload at offset ${start}`
|
||||
)
|
||||
await sleep(backoffTime)
|
||||
}
|
||||
core.info(
|
||||
`Finished backoff for retry #${retryCount}, continuing with upload`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// allow for failed chunks to be retried multiple times
|
||||
while (retryCount <= retryLimit) {
|
||||
let response: IHttpClientResponse
|
||||
|
||||
try {
|
||||
response = await uploadChunkRequest()
|
||||
} catch (error) {
|
||||
// if an error is caught, it is usually indicative of a timeout so retry the upload
|
||||
core.info(
|
||||
`An error has been caught http-client index ${httpClientIndex}, retrying the upload`
|
||||
)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(error)
|
||||
|
||||
if (incrementAndCheckRetryLimit()) {
|
||||
return false
|
||||
}
|
||||
await backOff()
|
||||
continue
|
||||
}
|
||||
|
||||
// Always read the body of the response. There is potential for a resource leak if the body is not read which will
|
||||
// result in the connection remaining open along with unintended consequences when trying to dispose of the client
|
||||
await response.readBody()
|
||||
|
||||
if (isSuccessStatusCode(response.message.statusCode)) {
|
||||
return true
|
||||
} else if (isRetryableStatusCode(response.message.statusCode)) {
|
||||
core.info(
|
||||
`A ${response.message.statusCode} status code has been received, will attempt to retry the upload`
|
||||
)
|
||||
if (incrementAndCheckRetryLimit(response)) {
|
||||
return false
|
||||
}
|
||||
isThrottledStatusCode(response.message.statusCode)
|
||||
? await backOff(
|
||||
tryGetRetryAfterValueTimeInMilliseconds(response.message.headers)
|
||||
)
|
||||
: await backOff()
|
||||
} else {
|
||||
core.error(
|
||||
`Unexpected response. Unable to upload chunk to ${resourceUrl}`
|
||||
)
|
||||
displayHttpDiagnostics(response)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the size of the artifact from -1 which was initially set when the container was first created for the artifact.
|
||||
* Updating the size indicates that we are done uploading all the contents of the artifact
|
||||
*/
|
||||
async patchArtifactSize(size: number, artifactName: string): Promise<void> {
|
||||
const resourceUrl = new URL(getArtifactUrl())
|
||||
resourceUrl.searchParams.append('artifactName', artifactName)
|
||||
|
||||
const parameters: PatchArtifactSize = {Size: size}
|
||||
const data: string = JSON.stringify(parameters, null, 2)
|
||||
core.debug(`URL is ${resourceUrl.toString()}`)
|
||||
|
||||
// use the first client from the httpManager, `keep-alive` is not used so the connection will close immediately
|
||||
const client = this.uploadHttpManager.getClient(0)
|
||||
const headers = getUploadHeaders('application/json', false)
|
||||
|
||||
// Extra information to display when a particular HTTP code is returned
|
||||
const customErrorMessages: Map<number, string> = new Map([
|
||||
[
|
||||
HttpCodes.NotFound,
|
||||
`An Artifact with the name ${artifactName} was not found`
|
||||
]
|
||||
])
|
||||
|
||||
// TODO retry for all possible response codes, the artifact upload is pretty much complete so it at all costs we should try to finish this
|
||||
const response = await retryHttpClientRequest(
|
||||
'Finalize artifact upload',
|
||||
async () => client.patch(resourceUrl.toString(), data, headers),
|
||||
customErrorMessages
|
||||
)
|
||||
await response.readBody()
|
||||
core.debug(
|
||||
`Artifact ${artifactName} has been successfully uploaded, total size in bytes: ${size}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface UploadFileParameters {
|
||||
file: string
|
||||
resourceUrl: string
|
||||
maxChunkSize: number
|
||||
continueOnError: boolean
|
||||
}
|
||||
|
||||
interface UploadFileResult {
|
||||
isSuccess: boolean
|
||||
successfulUploadSize: number
|
||||
totalSize: number
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
export interface UploadOptions {
|
||||
/**
|
||||
* Indicates if the artifact upload should continue if file or chunk fails to upload from any error.
|
||||
* If there is a error during upload, a partial artifact will always be associated and available for
|
||||
* download at the end. The size reported will be the amount of storage that the user or org will be
|
||||
* charged for the partial artifact. Defaults to true if not specified
|
||||
*
|
||||
* If set to false, and an error is encountered, all other uploads will stop and any files or chunks
|
||||
* that were queued will not be attempted to be uploaded. The partial artifact available will only
|
||||
* include files and chunks up until the failure
|
||||
*
|
||||
* If set to true and an error is encountered, the failed file will be skipped and ignored and all
|
||||
* other queued files will be attempted to be uploaded. The partial artifact at the end will have all
|
||||
* files with the exception of the problematic files(s)/chunks(s) that failed to upload
|
||||
*
|
||||
*/
|
||||
continueOnError?: boolean
|
||||
|
||||
/**
|
||||
* Duration after which artifact will expire in days.
|
||||
*
|
||||
* By default artifact expires after 90 days:
|
||||
* https://docs.github.com/en/actions/configuring-and-managing-workflows/persisting-workflow-data-using-artifacts#downloading-and-deleting-artifacts-after-a-workflow-run-is-complete
|
||||
*
|
||||
* Use this option to override the default expiry.
|
||||
*
|
||||
* Min value: 1
|
||||
* Max value: 90 unless changed by repository setting
|
||||
*
|
||||
* If this is set to a greater value than the retention settings allowed, the retention on artifacts
|
||||
* will be reduced to match the max value allowed on server, and the upload process will continue. An
|
||||
* input of 0 assumes default retention setting.
|
||||
*/
|
||||
retentionDays?: number
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
export interface UploadResponse {
|
||||
/**
|
||||
* The name of the artifact that was uploaded
|
||||
*/
|
||||
artifactName: string
|
||||
|
||||
/**
|
||||
* A list of all items that are meant to be uploaded as part of the artifact
|
||||
*/
|
||||
artifactItems: string[]
|
||||
|
||||
/**
|
||||
* Total size of the artifact in bytes that was uploaded
|
||||
*/
|
||||
size: number
|
||||
|
||||
/**
|
||||
* A list of items that were not uploaded as part of the artifact (includes queued items that were not uploaded if
|
||||
* continueOnError is set to false). This is a subset of artifactItems.
|
||||
*/
|
||||
failedItems: string[]
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import * as fs from 'fs'
|
||||
import {debug} from '@actions/core'
|
||||
import {join, normalize, resolve} from 'path'
|
||||
import {checkArtifactName, checkArtifactFilePath} from './utils'
|
||||
|
||||
export interface UploadSpecification {
|
||||
absoluteFilePath: string
|
||||
uploadFilePath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a specification that describes how each file that is part of the artifact will be uploaded
|
||||
* @param artifactName the name of the artifact being uploaded. Used during upload to denote where the artifact is stored on the server
|
||||
* @param rootDirectory an absolute file path that denotes the path that should be removed from the beginning of each artifact file
|
||||
* @param artifactFiles a list of absolute file paths that denote what should be uploaded as part of the artifact
|
||||
*/
|
||||
export function getUploadSpecification(
|
||||
artifactName: string,
|
||||
rootDirectory: string,
|
||||
artifactFiles: string[]
|
||||
): UploadSpecification[] {
|
||||
checkArtifactName(artifactName)
|
||||
|
||||
const specifications: UploadSpecification[] = []
|
||||
|
||||
if (!fs.existsSync(rootDirectory)) {
|
||||
throw new Error(`Provided rootDirectory ${rootDirectory} does not exist`)
|
||||
}
|
||||
if (!fs.lstatSync(rootDirectory).isDirectory()) {
|
||||
throw new Error(
|
||||
`Provided rootDirectory ${rootDirectory} is not a valid directory`
|
||||
)
|
||||
}
|
||||
// Normalize and resolve, this allows for either absolute or relative paths to be used
|
||||
rootDirectory = normalize(rootDirectory)
|
||||
rootDirectory = resolve(rootDirectory)
|
||||
|
||||
/*
|
||||
Example to demonstrate behavior
|
||||
|
||||
Input:
|
||||
artifactName: my-artifact
|
||||
rootDirectory: '/home/user/files/plz-upload'
|
||||
artifactFiles: [
|
||||
'/home/user/files/plz-upload/file1.txt',
|
||||
'/home/user/files/plz-upload/file2.txt',
|
||||
'/home/user/files/plz-upload/dir/file3.txt'
|
||||
]
|
||||
|
||||
Output:
|
||||
specifications: [
|
||||
['/home/user/files/plz-upload/file1.txt', 'my-artifact/file1.txt'],
|
||||
['/home/user/files/plz-upload/file1.txt', 'my-artifact/file2.txt'],
|
||||
['/home/user/files/plz-upload/file1.txt', 'my-artifact/dir/file3.txt']
|
||||
]
|
||||
*/
|
||||
for (let file of artifactFiles) {
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(`File ${file} does not exist`)
|
||||
}
|
||||
if (!fs.lstatSync(file).isDirectory()) {
|
||||
// Normalize and resolve, this allows for either absolute or relative paths to be used
|
||||
file = normalize(file)
|
||||
file = resolve(file)
|
||||
if (!file.startsWith(rootDirectory)) {
|
||||
throw new Error(
|
||||
`The rootDirectory: ${rootDirectory} is not a parent directory of the file: ${file}`
|
||||
)
|
||||
}
|
||||
|
||||
// Check for forbidden characters in file paths that will be rejected during upload
|
||||
const uploadPath = file.replace(rootDirectory, '')
|
||||
checkArtifactFilePath(uploadPath)
|
||||
|
||||
/*
|
||||
uploadFilePath denotes where the file will be uploaded in the file container on the server. During a run, if multiple artifacts are uploaded, they will all
|
||||
be saved in the same container. The artifact name is used as the root directory in the container to separate and distinguish uploaded artifacts
|
||||
|
||||
path.join handles all the following cases and would return 'artifact-name/file-to-upload.txt
|
||||
join('artifact-name/', 'file-to-upload.txt')
|
||||
join('artifact-name/', '/file-to-upload.txt')
|
||||
join('artifact-name', 'file-to-upload.txt')
|
||||
join('artifact-name', '/file-to-upload.txt')
|
||||
*/
|
||||
specifications.push({
|
||||
absoluteFilePath: file,
|
||||
uploadFilePath: join(artifactName, uploadPath)
|
||||
})
|
||||
} else {
|
||||
// Directories are rejected by the server during upload
|
||||
debug(`Removing ${file} from rawSearchResults because it is a directory`)
|
||||
}
|
||||
}
|
||||
return specifications
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {info} from '@actions/core'
|
||||
|
||||
/**
|
||||
* Invalid characters that cannot be in the artifact name or an uploaded file. Will be rejected
|
||||
* from the server if attempted to be sent over. These characters are not allowed due to limitations with certain
|
||||
* file systems such as NTFS. To maintain platform-agnostic behavior, all characters that are not supported by an
|
||||
* individual filesystem/platform will not be supported on all fileSystems/platforms
|
||||
*
|
||||
* FilePaths can include characters such as \ and / which are not permitted in the artifact name alone
|
||||
*/
|
||||
const invalidArtifactFilePathCharacters = new Map<string, string>([
|
||||
['"', ' Double quote "'],
|
||||
[':', ' Colon :'],
|
||||
['<', ' Less than <'],
|
||||
['>', ' Greater than >'],
|
||||
['|', ' Vertical bar |'],
|
||||
['*', ' Asterisk *'],
|
||||
['?', ' Question mark ?'],
|
||||
['\r', ' Carriage return \\r'],
|
||||
['\n', ' Line feed \\n']
|
||||
])
|
||||
|
||||
const invalidArtifactNameCharacters = new Map<string, string>([
|
||||
...invalidArtifactFilePathCharacters,
|
||||
['\\', ' Backslash \\'],
|
||||
['/', ' Forward slash /']
|
||||
])
|
||||
|
||||
/**
|
||||
* Validates the name of the artifact to check to make sure there are no illegal characters
|
||||
*/
|
||||
export function validateArtifactName(name: string): void {
|
||||
if (!name) {
|
||||
throw new Error(`Provided artifact name input during validation is empty`)
|
||||
}
|
||||
|
||||
for (const [
|
||||
invalidCharacterKey,
|
||||
errorMessageForCharacter
|
||||
] of invalidArtifactNameCharacters) {
|
||||
if (name.includes(invalidCharacterKey)) {
|
||||
throw new Error(
|
||||
`The artifact name is not valid: ${name}. Contains the following character: ${errorMessageForCharacter}
|
||||
|
||||
Invalid characters include: ${Array.from(
|
||||
invalidArtifactNameCharacters.values()
|
||||
).toString()}
|
||||
|
||||
These characters are not allowed in the artifact name due to limitations with certain file systems such as NTFS. To maintain file system agnostic behavior, these characters are intentionally not allowed to prevent potential problems with downloads on different file systems.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
info(`Artifact name is valid!`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates file paths to check for any illegal characters that can cause problems on different file systems
|
||||
*/
|
||||
export function validateFilePath(path: string): void {
|
||||
if (!path) {
|
||||
throw new Error(`Provided file path input during validation is empty`)
|
||||
}
|
||||
|
||||
for (const [
|
||||
invalidCharacterKey,
|
||||
errorMessageForCharacter
|
||||
] of invalidArtifactFilePathCharacters) {
|
||||
if (path.includes(invalidCharacterKey)) {
|
||||
throw new Error(
|
||||
`The path for one of the files in artifact is not valid: ${path}. Contains the following character: ${errorMessageForCharacter}
|
||||
|
||||
Invalid characters include: ${Array.from(
|
||||
invalidArtifactFilePathCharacters.values()
|
||||
).toString()}
|
||||
|
||||
The following characters are not allowed in files that are uploaded due to limitations with certain file systems such as NTFS. To maintain file system agnostic behavior, these characters are intentionally not allowed to prevent potential problems with downloads on different file systems.
|
||||
`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as core from '@actions/core'
|
||||
import {UploadOptions} from './upload-options'
|
||||
import {UploadResponse} from './upload-response'
|
||||
import {validateArtifactName} from './path-and-artifact-name-validation'
|
||||
import {
|
||||
UploadZipSpecification,
|
||||
getUploadZipSpecification,
|
||||
validateRootDirectory
|
||||
} from './upload-zip-specification'
|
||||
|
||||
export async function uploadArtifact(
|
||||
name: string,
|
||||
files: string[],
|
||||
rootDirectory: string,
|
||||
options?: UploadOptions | undefined // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
): Promise<UploadResponse> {
|
||||
validateArtifactName(name)
|
||||
validateRootDirectory(rootDirectory)
|
||||
|
||||
const zipSpecification: UploadZipSpecification[] = getUploadZipSpecification(
|
||||
files,
|
||||
rootDirectory
|
||||
)
|
||||
if (zipSpecification.length === 0) {
|
||||
core.warning(`No files were found to upload`)
|
||||
return {
|
||||
success: false
|
||||
}
|
||||
}
|
||||
|
||||
// TODO - Implement upload functionality
|
||||
|
||||
const uploadResponse: UploadResponse = {
|
||||
success: true,
|
||||
size: 0,
|
||||
id: 0
|
||||
}
|
||||
|
||||
return uploadResponse
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface UploadOptions {
|
||||
/**
|
||||
* Duration after which artifact will expire in days.
|
||||
*
|
||||
* By default artifact expires after 90 days:
|
||||
* https://docs.github.com/en/actions/configuring-and-managing-workflows/persisting-workflow-data-using-artifacts#downloading-and-deleting-artifacts-after-a-workflow-run-is-complete
|
||||
*
|
||||
* Use this option to override the default expiry.
|
||||
*
|
||||
* Min value: 1
|
||||
* Max value: 90 unless changed by repository setting
|
||||
*
|
||||
* If this is set to a greater value than the retention settings allowed, the retention on artifacts
|
||||
* will be reduced to match the max value allowed on server, and the upload process will continue. An
|
||||
* input of 0 assumes default retention setting.
|
||||
*/
|
||||
retentionDays?: number
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface UploadResponse {
|
||||
/**
|
||||
* Denotes if an artifact was successfully uploaded
|
||||
*/
|
||||
success: boolean
|
||||
|
||||
/**
|
||||
* Total size of the artifact in bytes. Not provided if no artifact was uploaded
|
||||
*/
|
||||
size?: number
|
||||
|
||||
/**
|
||||
* The id of the artifact that was created. Not provided if no artifact was uploaded
|
||||
* This ID can be used as input to other APIs to download, delete or get more information about an artifact: https://docs.github.com/en/rest/actions/artifacts
|
||||
*/
|
||||
id?: number
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import * as fs from 'fs'
|
||||
import {info} from '@actions/core'
|
||||
import {normalize, resolve} from 'path'
|
||||
import {validateFilePath} from './path-and-artifact-name-validation'
|
||||
|
||||
export interface UploadZipSpecification {
|
||||
/**
|
||||
* An absolute source path that points to a file that will be added to a zip. Null if creating a new directory
|
||||
*/
|
||||
sourcePath: string | null
|
||||
|
||||
/**
|
||||
* The destination path in a zip for a file
|
||||
*/
|
||||
destinationPath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a root directory exists and is valid
|
||||
* @param rootDirectory an absolute root directory path common to all input files that that will be trimmed from the final zip structure
|
||||
*/
|
||||
export function validateRootDirectory(rootDirectory: string): void {
|
||||
if (!fs.existsSync(rootDirectory)) {
|
||||
throw new Error(
|
||||
`The provided rootDirectory ${rootDirectory} does not exist`
|
||||
)
|
||||
}
|
||||
if (!fs.statSync(rootDirectory).isDirectory()) {
|
||||
throw new Error(
|
||||
`The provided rootDirectory ${rootDirectory} is not a valid directory`
|
||||
)
|
||||
}
|
||||
info(`Root directory input is valid!`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a specification that describes how a zip file will be created for a set of input files
|
||||
* @param filesToZip a list of file that should be included in the zip
|
||||
* @param rootDirectory an absolute root directory path common to all input files that that will be trimmed from the final zip structure
|
||||
*/
|
||||
export function getUploadZipSpecification(
|
||||
filesToZip: string[],
|
||||
rootDirectory: string
|
||||
): UploadZipSpecification[] {
|
||||
const specification: UploadZipSpecification[] = []
|
||||
|
||||
// Normalize and resolve, this allows for either absolute or relative paths to be used
|
||||
rootDirectory = normalize(rootDirectory)
|
||||
rootDirectory = resolve(rootDirectory)
|
||||
|
||||
/*
|
||||
Example
|
||||
|
||||
Input:
|
||||
rootDirectory: '/home/user/files/plz-upload'
|
||||
artifactFiles: [
|
||||
'/home/user/files/plz-upload/file1.txt',
|
||||
'/home/user/files/plz-upload/file2.txt',
|
||||
'/home/user/files/plz-upload/dir/file3.txt'
|
||||
]
|
||||
|
||||
Output:
|
||||
specifications: [
|
||||
['/home/user/files/plz-upload/file1.txt', '/file1.txt'],
|
||||
['/home/user/files/plz-upload/file1.txt', '/file2.txt'],
|
||||
['/home/user/files/plz-upload/file1.txt', '/dir/file3.txt']
|
||||
]
|
||||
|
||||
The final zip that is later uploaded will look like this:
|
||||
|
||||
my-artifact.zip
|
||||
- file.txt
|
||||
- file2.txt
|
||||
- dir/
|
||||
- file3.txt
|
||||
*/
|
||||
for (let file of filesToZip) {
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(`File ${file} does not exist`)
|
||||
}
|
||||
if (!fs.statSync(file).isDirectory()) {
|
||||
// Normalize and resolve, this allows for either absolute or relative paths to be used
|
||||
file = normalize(file)
|
||||
file = resolve(file)
|
||||
if (!file.startsWith(rootDirectory)) {
|
||||
throw new Error(
|
||||
`The rootDirectory: ${rootDirectory} is not a parent directory of the file: ${file}`
|
||||
)
|
||||
}
|
||||
|
||||
// Check for forbidden characters in file paths that may cause ambiguous behavior if downloaded on different file systems
|
||||
const uploadPath = file.replace(rootDirectory, '')
|
||||
validateFilePath(uploadPath)
|
||||
|
||||
specification.push({
|
||||
sourcePath: file,
|
||||
destinationPath: uploadPath
|
||||
})
|
||||
} else {
|
||||
// Empty directory
|
||||
const directoryPath = file.replace(rootDirectory, '')
|
||||
validateFilePath(directoryPath)
|
||||
|
||||
specification.push({
|
||||
sourcePath: null,
|
||||
destinationPath: directoryPath
|
||||
})
|
||||
}
|
||||
}
|
||||
return specification
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
import {debug, info, warning} from '@actions/core'
|
||||
import {promises as fs} from 'fs'
|
||||
import {HttpCodes, HttpClient} from '@actions/http-client'
|
||||
import {BearerCredentialHandler} from '@actions/http-client/auth'
|
||||
import {IHeaders, IHttpClientResponse} from '@actions/http-client/interfaces'
|
||||
import {IncomingHttpHeaders} from 'http'
|
||||
import {
|
||||
getRuntimeToken,
|
||||
getRuntimeUrl,
|
||||
getWorkFlowRunId,
|
||||
getRetryMultiplier,
|
||||
getInitialRetryIntervalInMilliseconds
|
||||
} from './config-variables'
|
||||
|
||||
/**
|
||||
* Returns a retry time in milliseconds that exponentially gets larger
|
||||
* depending on the amount of retries that have been attempted
|
||||
*/
|
||||
export function getExponentialRetryTimeInMilliseconds(
|
||||
retryCount: number
|
||||
): number {
|
||||
if (retryCount < 0) {
|
||||
throw new Error('RetryCount should not be negative')
|
||||
} else if (retryCount === 0) {
|
||||
return getInitialRetryIntervalInMilliseconds()
|
||||
}
|
||||
|
||||
const minTime =
|
||||
getInitialRetryIntervalInMilliseconds() * getRetryMultiplier() * retryCount
|
||||
const maxTime = minTime * getRetryMultiplier()
|
||||
|
||||
// returns a random number between the minTime (inclusive) and the maxTime (exclusive)
|
||||
return Math.random() * (maxTime - minTime) + minTime
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a env variable that is a number
|
||||
*/
|
||||
export function parseEnvNumber(key: string): number | undefined {
|
||||
const value = Number(process.env[key])
|
||||
if (Number.isNaN(value) || value < 0) {
|
||||
return undefined
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Various utility functions to help with the necessary API calls
|
||||
*/
|
||||
export function getApiVersion(): string {
|
||||
return '6.0-preview'
|
||||
}
|
||||
|
||||
export function isSuccessStatusCode(statusCode?: number): boolean {
|
||||
if (!statusCode) {
|
||||
return false
|
||||
}
|
||||
return statusCode >= 200 && statusCode < 300
|
||||
}
|
||||
|
||||
export function isForbiddenStatusCode(statusCode?: number): boolean {
|
||||
if (!statusCode) {
|
||||
return false
|
||||
}
|
||||
return statusCode === HttpCodes.Forbidden
|
||||
}
|
||||
|
||||
export function isRetryableStatusCode(statusCode: number | undefined): boolean {
|
||||
if (!statusCode) {
|
||||
return false
|
||||
}
|
||||
|
||||
const retryableStatusCodes = [
|
||||
HttpCodes.BadGateway,
|
||||
HttpCodes.ServiceUnavailable,
|
||||
HttpCodes.GatewayTimeout,
|
||||
HttpCodes.TooManyRequests,
|
||||
413 // Payload Too Large
|
||||
]
|
||||
return retryableStatusCodes.includes(statusCode)
|
||||
}
|
||||
|
||||
export function isThrottledStatusCode(statusCode?: number): boolean {
|
||||
if (!statusCode) {
|
||||
return false
|
||||
}
|
||||
return statusCode === HttpCodes.TooManyRequests
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to get the retry-after value from a set of http headers. The retry time
|
||||
* is originally denoted in seconds, so if present, it is converted to milliseconds
|
||||
* @param headers all the headers received when making an http call
|
||||
*/
|
||||
export function tryGetRetryAfterValueTimeInMilliseconds(
|
||||
headers: IncomingHttpHeaders
|
||||
): number | undefined {
|
||||
if (headers['retry-after']) {
|
||||
const retryTime = Number(headers['retry-after'])
|
||||
if (!isNaN(retryTime)) {
|
||||
info(`Retry-After header is present with a value of ${retryTime}`)
|
||||
return retryTime * 1000
|
||||
}
|
||||
info(
|
||||
`Returned retry-after header value: ${retryTime} is non-numeric and cannot be used`
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
info(
|
||||
`No retry-after header was found. Dumping all headers for diagnostic purposes`
|
||||
)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(headers)
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getContentRange(
|
||||
start: number,
|
||||
end: number,
|
||||
total: number
|
||||
): string {
|
||||
// Format: `bytes start-end/fileSize
|
||||
// start and end are inclusive
|
||||
// For a 200 byte chunk starting at byte 0:
|
||||
// Content-Range: bytes 0-199/200
|
||||
return `bytes ${start}-${end}/${total}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets all the necessary headers when downloading an artifact
|
||||
* @param {string} contentType the type of content being uploaded
|
||||
* @param {boolean} isKeepAlive is the same connection being used to make multiple calls
|
||||
* @param {boolean} acceptGzip can we accept a gzip encoded response
|
||||
* @param {string} acceptType the type of content that we can accept
|
||||
* @returns appropriate headers to make a specific http call during artifact download
|
||||
*/
|
||||
export function getDownloadHeaders(
|
||||
contentType: string,
|
||||
isKeepAlive?: boolean,
|
||||
acceptGzip?: boolean
|
||||
): IHeaders {
|
||||
const requestOptions: IHeaders = {}
|
||||
|
||||
if (contentType) {
|
||||
requestOptions['Content-Type'] = contentType
|
||||
}
|
||||
if (isKeepAlive) {
|
||||
requestOptions['Connection'] = 'Keep-Alive'
|
||||
// keep alive for at least 10 seconds before closing the connection
|
||||
requestOptions['Keep-Alive'] = '10'
|
||||
}
|
||||
if (acceptGzip) {
|
||||
// if we are expecting a response with gzip encoding, it should be using an octet-stream in the accept header
|
||||
requestOptions['Accept-Encoding'] = 'gzip'
|
||||
requestOptions[
|
||||
'Accept'
|
||||
] = `application/octet-stream;api-version=${getApiVersion()}`
|
||||
} else {
|
||||
// default to application/json if we are not working with gzip content
|
||||
requestOptions['Accept'] = `application/json;api-version=${getApiVersion()}`
|
||||
}
|
||||
|
||||
return requestOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets all the necessary headers when uploading an artifact
|
||||
* @param {string} contentType the type of content being uploaded
|
||||
* @param {boolean} isKeepAlive is the same connection being used to make multiple calls
|
||||
* @param {boolean} isGzip is the connection being used to upload GZip compressed content
|
||||
* @param {number} uncompressedLength the original size of the content if something is being uploaded that has been compressed
|
||||
* @param {number} contentLength the length of the content that is being uploaded
|
||||
* @param {string} contentRange the range of the content that is being uploaded
|
||||
* @returns appropriate headers to make a specific http call during artifact upload
|
||||
*/
|
||||
export function getUploadHeaders(
|
||||
contentType: string,
|
||||
isKeepAlive?: boolean,
|
||||
isGzip?: boolean,
|
||||
uncompressedLength?: number,
|
||||
contentLength?: number,
|
||||
contentRange?: string
|
||||
): IHeaders {
|
||||
const requestOptions: IHeaders = {}
|
||||
requestOptions['Accept'] = `application/json;api-version=${getApiVersion()}`
|
||||
if (contentType) {
|
||||
requestOptions['Content-Type'] = contentType
|
||||
}
|
||||
if (isKeepAlive) {
|
||||
requestOptions['Connection'] = 'Keep-Alive'
|
||||
// keep alive for at least 10 seconds before closing the connection
|
||||
requestOptions['Keep-Alive'] = '10'
|
||||
}
|
||||
if (isGzip) {
|
||||
requestOptions['Content-Encoding'] = 'gzip'
|
||||
requestOptions['x-tfs-filelength'] = uncompressedLength
|
||||
}
|
||||
if (contentLength) {
|
||||
requestOptions['Content-Length'] = contentLength
|
||||
}
|
||||
if (contentRange) {
|
||||
requestOptions['Content-Range'] = contentRange
|
||||
}
|
||||
|
||||
return requestOptions
|
||||
}
|
||||
|
||||
export function createHttpClient(userAgent: string): HttpClient {
|
||||
return new HttpClient(userAgent, [
|
||||
new BearerCredentialHandler(getRuntimeToken())
|
||||
])
|
||||
}
|
||||
|
||||
export function getArtifactUrl(): string {
|
||||
const artifactUrl = `${getRuntimeUrl()}_apis/pipelines/workflows/${getWorkFlowRunId()}/artifacts?api-version=${getApiVersion()}`
|
||||
debug(`Artifact Url: ${artifactUrl}`)
|
||||
return artifactUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Uh oh! Something might have gone wrong during either upload or download. The IHtttpClientResponse object contains information
|
||||
* about the http call that was made by the actions http client. This information might be useful to display for diagnostic purposes, but
|
||||
* this entire object is really big and most of the information is not really useful. This function takes the response object and displays only
|
||||
* the information that we want.
|
||||
*
|
||||
* Certain information such as the TLSSocket and the Readable state are not really useful for diagnostic purposes so they can be avoided.
|
||||
* Other information such as the headers, the response code and message might be useful, so this is displayed.
|
||||
*/
|
||||
export function displayHttpDiagnostics(response: IHttpClientResponse): void {
|
||||
info(
|
||||
`##### Begin Diagnostic HTTP information #####
|
||||
Status Code: ${response.message.statusCode}
|
||||
Status Message: ${response.message.statusMessage}
|
||||
Header Information: ${JSON.stringify(response.message.headers, undefined, 2)}
|
||||
###### End Diagnostic HTTP information ######`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalid characters that cannot be in the artifact name or an uploaded file. Will be rejected
|
||||
* from the server if attempted to be sent over. These characters are not allowed due to limitations with certain
|
||||
* file systems such as NTFS. To maintain platform-agnostic behavior, all characters that are not supported by an
|
||||
* individual filesystem/platform will not be supported on all fileSystems/platforms
|
||||
*
|
||||
* FilePaths can include characters such as \ and / which are not permitted in the artifact name alone
|
||||
*/
|
||||
const invalidArtifactFilePathCharacters = ['"', ':', '<', '>', '|', '*', '?']
|
||||
const invalidArtifactNameCharacters = [
|
||||
...invalidArtifactFilePathCharacters,
|
||||
'\\',
|
||||
'/'
|
||||
]
|
||||
|
||||
/**
|
||||
* Scans the name of the artifact to make sure there are no illegal characters
|
||||
*/
|
||||
export function checkArtifactName(name: string): void {
|
||||
if (!name) {
|
||||
throw new Error(`Artifact name: ${name}, is incorrectly provided`)
|
||||
}
|
||||
|
||||
for (const invalidChar of invalidArtifactNameCharacters) {
|
||||
if (name.includes(invalidChar)) {
|
||||
throw new Error(
|
||||
`Artifact name is not valid: ${name}. Contains character: "${invalidChar}". Invalid artifact name characters include: ${invalidArtifactNameCharacters.toString()}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the name of the filePath used to make sure there are no illegal characters
|
||||
*/
|
||||
export function checkArtifactFilePath(path: string): void {
|
||||
if (!path) {
|
||||
throw new Error(`Artifact path: ${path}, is incorrectly provided`)
|
||||
}
|
||||
|
||||
for (const invalidChar of invalidArtifactFilePathCharacters) {
|
||||
if (path.includes(invalidChar)) {
|
||||
throw new Error(
|
||||
`Artifact path is not valid: ${path}. Contains character: "${invalidChar}". Invalid characters include: ${invalidArtifactFilePathCharacters.toString()}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDirectoriesForArtifact(
|
||||
directories: string[]
|
||||
): Promise<void> {
|
||||
for (const directory of directories) {
|
||||
await fs.mkdir(directory, {
|
||||
recursive: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function createEmptyFilesForArtifact(
|
||||
emptyFilesToCreate: string[]
|
||||
): Promise<void> {
|
||||
for (const filePath of emptyFilesToCreate) {
|
||||
await (await fs.open(filePath, 'w')).close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFileSize(filePath: string): Promise<number> {
|
||||
const stats = await fs.stat(filePath)
|
||||
debug(
|
||||
`${filePath} size:(${stats.size}) blksize:(${stats.blksize}) blocks:(${stats.blocks})`
|
||||
)
|
||||
return stats.size
|
||||
}
|
||||
|
||||
export async function rmFile(filePath: string): Promise<void> {
|
||||
await fs.unlink(filePath)
|
||||
}
|
||||
|
||||
export function getProperRetention(
|
||||
retentionInput: number,
|
||||
retentionSetting: string | undefined
|
||||
): number {
|
||||
if (retentionInput < 0) {
|
||||
throw new Error('Invalid retention, minimum value is 1.')
|
||||
}
|
||||
|
||||
let retention = retentionInput
|
||||
if (retentionSetting) {
|
||||
const maxRetention = parseInt(retentionSetting)
|
||||
if (!isNaN(maxRetention) && maxRetention < retention) {
|
||||
warning(
|
||||
`Retention days is greater than the max value allowed by the repository setting, reduce retention to ${maxRetention} days`
|
||||
)
|
||||
retention = maxRetention
|
||||
}
|
||||
}
|
||||
return retention
|
||||
}
|
||||
|
||||
export async function sleep(milliseconds: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
@@ -3,7 +3,16 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "./lib",
|
||||
"rootDir": "./src"
|
||||
"rootDir": "./src",
|
||||
"paths": {
|
||||
"@actions/core": [
|
||||
"../core"
|
||||
],
|
||||
"@actions/http-client": [
|
||||
"../http-client"
|
||||
]
|
||||
},
|
||||
"useUnknownInCatchVariables": false
|
||||
},
|
||||
"include": [
|
||||
"./src"
|
||||
|
||||
Vendored
+22
-15
@@ -2,17 +2,31 @@
|
||||
|
||||
> Functions necessary for caching dependencies and build outputs to improve workflow execution time.
|
||||
|
||||
See ["Caching dependencies to speed up workflows"](https://help.github.com/github/automating-your-workflow-with-github-actions/caching-dependencies-to-speed-up-workflows) for how caching works.
|
||||
See ["Caching dependencies to speed up workflows"](https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows) for how caching works.
|
||||
|
||||
Note that GitHub will remove any cache entries that have not been accessed in over 7 days. There is no limit on the number of caches you can store, but the total size of all caches in a repository is limited to 5 GB. If you exceed this limit, GitHub will save your cache but will begin evicting caches until the total size is less than 5 GB.
|
||||
Note that GitHub will remove any cache entries that have not been accessed in over 7 days. There is no limit on the number of caches you can store, but the total size of all caches in a repository is limited to 10 GB. If you exceed this limit, GitHub will save your cache but will begin evicting caches until the total size is less than 10 GB.
|
||||
|
||||
## Usage
|
||||
|
||||
This package is used by the v2+ versions of our first party cache action. You can find an example implementation in the cache repo [here](https://github.com/actions/cache).
|
||||
This package is used by the v2+ versions of our first party cache action. You can find an example implementation in the cache repo [here](https://github.com/actions/cache).
|
||||
|
||||
#### Save Cache
|
||||
|
||||
Saves a cache containing the files in `paths` using the `key` provided. The files would be compressed using zstandard compression algorithm if zstd is installed, otherwise gzip is used. Function returns the cache id if the cache was saved succesfully and throws an error if cache upload fails.
|
||||
|
||||
```js
|
||||
const cache = require('@actions/cache');
|
||||
const paths = [
|
||||
'node_modules',
|
||||
'packages/*/node_modules/'
|
||||
]
|
||||
const key = 'npm-foobar-d5ea0750'
|
||||
const cacheId = await cache.saveCache(paths, key)
|
||||
```
|
||||
|
||||
#### Restore Cache
|
||||
|
||||
Restores a cache based on `key` and `restoreKeys` to the `paths` provided. Function returns the cache key for cache hit and returns undefined if cache not found.
|
||||
Restores a cache based on `key` and `restoreKeys` to the `paths` provided. Function returns the cache key for cache hit and returns undefined if cache not found.
|
||||
|
||||
```js
|
||||
const cache = require('@actions/cache');
|
||||
@@ -28,17 +42,10 @@ const restoreKeys = [
|
||||
const cacheKey = await cache.restoreCache(paths, key, restoreKeys)
|
||||
```
|
||||
|
||||
#### Save Cache
|
||||
##### Cache segment restore timeout
|
||||
|
||||
Saves a cache containing the files in `paths` using the `key` provided. The files would be compressed using zstandard compression algorithm if zstd is installed, otherwise gzip is used. Function returns the cache id if the cache was saved succesfully and throws an error if cache upload fails.
|
||||
A cache gets downloaded in multiple segments of fixed sizes (now `128MB` to fail-fast, previously `1GB` for a `32-bit` runner and `2GB` for a `64-bit` runner were used). Sometimes, a segment download gets stuck which causes the workflow job to be stuck forever and fail. Version `v3.0.4` of cache package introduces a segment download timeout. The segment download timeout will allow the segment download to get aborted and hence allow the job to proceed with a cache miss.
|
||||
|
||||
Default value of this timeout is 10 minutes (starting `v3.2.1` and higher, previously 60 minutes in versions between `v.3.0.4` and `v3.2.0`, both included) and can be customized by specifying an [environment variable](https://docs.github.com/en/actions/learn-github-actions/environment-variables) named `SEGMENT_DOWNLOAD_TIMEOUT_MINS` with timeout value in minutes.
|
||||
|
||||
```js
|
||||
const cache = require('@actions/cache');
|
||||
const paths = [
|
||||
'node_modules',
|
||||
'packages/*/node_modules/'
|
||||
]
|
||||
const key = 'npm-foobar-d5ea0750'
|
||||
const cacheId = await cache.saveCache(paths, key)
|
||||
```
|
||||
|
||||
|
||||
Vendored
+124
-1
@@ -5,35 +5,158 @@
|
||||
- Initial release
|
||||
|
||||
### 0.2.0
|
||||
|
||||
- Fixes issues with the zstd compression algorithm on Windows and Ubuntu 16.04 [#469](https://github.com/actions/toolkit/pull/469)
|
||||
|
||||
### 0.2.1
|
||||
|
||||
- Fix to await async function getCompressionMethod
|
||||
|
||||
### 1.0.0
|
||||
|
||||
- Downloads Azure-hosted caches using the Azure SDK for speed and reliability
|
||||
- Displays download progress
|
||||
- Includes changes that break compatibility with earlier versions, including:
|
||||
- `retry`, `retryTypedResponse`, and `retryHttpClientResponse` moved from `cacheHttpClient` to `requestUtils`
|
||||
|
||||
### 1.0.1
|
||||
|
||||
- Fix bug in downloading large files (> 2 GBs) with the Azure SDK
|
||||
|
||||
### 1.0.2
|
||||
|
||||
- Use posix archive format to add support for some tools
|
||||
|
||||
### 1.0.3
|
||||
|
||||
- Use http-client v1.0.9
|
||||
- Fixes error handling so retries are not attempted on non-retryable errors (409 Conflict, for example)
|
||||
- Adds 5 second delay between retry attempts
|
||||
|
||||
### 1.0.4
|
||||
|
||||
- Use @actions/core v1.2.6
|
||||
- Fixes uploadChunk to throw an error if any unsuccessful response code is received
|
||||
|
||||
### 1.0.5
|
||||
|
||||
- Fix to ensure Windows cache paths get resolved correctly
|
||||
|
||||
### 1.0.6
|
||||
|
||||
- Make caching more verbose [#650](https://github.com/actions/toolkit/pull/650)
|
||||
- Use GNU tar on macOS if available [#701](https://github.com/actions/toolkit/pull/701)
|
||||
- Use GNU tar on macOS if available [#701](https://github.com/actions/toolkit/pull/701)
|
||||
|
||||
### 1.0.7
|
||||
|
||||
- Fixes permissions issue extracting archives with GNU tar on macOS ([issue](https://github.com/actions/cache/issues/527))
|
||||
|
||||
### 1.0.8
|
||||
|
||||
- Increase the allowed artifact cache size from 5GB to 10GB ([issue](https://github.com/actions/cache/discussions/497))
|
||||
|
||||
### 1.0.9
|
||||
|
||||
- Use @azure/ms-rest-js v2.6.0
|
||||
- Use @azure/storage-blob v12.8.0
|
||||
|
||||
### 1.0.10
|
||||
|
||||
- Update `lockfileVersion` to `v2` in `package-lock.json [#1022](https://github.com/actions/toolkit/pull/1022)
|
||||
|
||||
### 1.0.11
|
||||
|
||||
- Fix file downloads > 2GB([issue](https://github.com/actions/cache/issues/773))
|
||||
|
||||
### 2.0.0
|
||||
|
||||
- Added support to check if Actions cache service feature is available or not [#1028](https://github.com/actions/toolkit/pull/1028)
|
||||
|
||||
### 2.0.3
|
||||
|
||||
- Update to v2.0.0 of `@actions/http-client`
|
||||
|
||||
### 2.0.4
|
||||
|
||||
- Update to v2.0.1 of `@actions/http-client` [#1087](https://github.com/actions/toolkit/pull/1087)
|
||||
|
||||
### 2.0.5
|
||||
|
||||
- Fix to avoid saving empty cache when no files are available for caching. ([issue](https://github.com/actions/cache/issues/624))
|
||||
|
||||
### 2.0.6
|
||||
|
||||
- Fix `Tar failed with error: The process '/usr/bin/tar' failed with exit code 1` issue when temp directory where tar is getting created is actually the subdirectory of the path mentioned by the user for caching. ([issue](https://github.com/actions/cache/issues/689))
|
||||
|
||||
### 3.0.0
|
||||
|
||||
- Updated actions/cache to suppress Actions cache server error and log warning for those error [#1122](https://github.com/actions/toolkit/pull/1122)
|
||||
|
||||
### 3.0.1
|
||||
|
||||
- Fix [#833](https://github.com/actions/cache/issues/833) - cache doesn't work with github workspace directory.
|
||||
- Fix [#809](https://github.com/actions/cache/issues/809) `zstd -d: no such file or directory` error on AWS self-hosted runners.
|
||||
|
||||
### 3.0.2
|
||||
|
||||
- Added 1 hour timeout for the download stuck issue [#810](https://github.com/actions/cache/issues/810).
|
||||
|
||||
### 3.0.3
|
||||
|
||||
- Bug fixes for download stuck issue [#810](https://github.com/actions/cache/issues/810).
|
||||
|
||||
### 3.0.4
|
||||
|
||||
- Fix zstd not working for windows on gnu tar in issues [#888](https://github.com/actions/cache/issues/888) and [#891](https://github.com/actions/cache/issues/891).
|
||||
- Allowing users to provide a custom timeout as input for aborting download of a cache segment using an environment variable `SEGMENT_DOWNLOAD_TIMEOUT_MINS`. Default is 60 minutes.
|
||||
|
||||
### 3.0.5
|
||||
|
||||
- Update `@actions/cache` to use `@actions/core@^1.10.0`
|
||||
|
||||
### 3.0.6
|
||||
|
||||
- Added `@azure/abort-controller` to dependencies to fix compatibility issue with ESM [#1208](https://github.com/actions/toolkit/issues/1208)
|
||||
|
||||
### 3.1.0-beta.1
|
||||
|
||||
- Update actions/cache on windows to use gnu tar and zstd by default and fallback to bsdtar and zstd if gnu tar is not available. ([issue](https://github.com/actions/cache/issues/984))
|
||||
|
||||
### 3.1.0-beta.2
|
||||
|
||||
- Added support for fallback to gzip to restore old caches on windows.
|
||||
|
||||
### 3.1.0-beta.3
|
||||
|
||||
- Bug Fixes for fallback to gzip to restore old caches on windows and bsdtar if gnutar is not available.
|
||||
|
||||
### 3.1.0
|
||||
|
||||
- Update actions/cache on windows to use gnu tar and zstd by default
|
||||
- Update actions/cache on windows to fallback to bsdtar and zstd if gnu tar is not available.
|
||||
- Added support for fallback to gzip to restore old caches on windows.
|
||||
|
||||
### 3.1.1
|
||||
|
||||
- Reverted changes in 3.1.0 to fix issue with symlink restoration on windows.
|
||||
- Added support for verbose logging about cache version during cache miss.
|
||||
|
||||
### 3.1.2
|
||||
|
||||
- Fix issue with symlink restoration on windows.
|
||||
|
||||
### 3.1.3
|
||||
|
||||
- Fix to prevent from setting MYSYS environement variable globally [#1329](https://github.com/actions/toolkit/pull/1329).
|
||||
|
||||
### 3.1.4
|
||||
|
||||
- Fix zstd not being used due to `zstd --version` output change in zstd 1.5.4 release. See [#1353](https://github.com/actions/toolkit/pull/1353).
|
||||
|
||||
### 3.2.0
|
||||
|
||||
- Add `lookupOnly` to cache restore `DownloadOptions`.
|
||||
|
||||
### 3.2.1
|
||||
|
||||
- Updated @azure/storage-blob to `v12.13.0`
|
||||
|
||||
+2
-2
@@ -3,10 +3,10 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const filePath = process.env[`GITHUB_ENV`]
|
||||
fs.appendFileSync(filePath, `ACTIONS_RUNTIME_URL=${process.env.ACTIONS_RUNTIME_URL}${os.EOL}`, {
|
||||
fs.appendFileSync(filePath, `ACTIONS_RUNTIME_TOKEN=${process.env.ACTIONS_RUNTIME_TOKEN}${os.EOL}`, {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
fs.appendFileSync(filePath, `ACTIONS_RUNTIME_TOKEN=${process.env.ACTIONS_RUNTIME_TOKEN}${os.EOL}`, {
|
||||
fs.appendFileSync(filePath, `ACTIONS_CACHE_URL=${process.env.ACTIONS_CACHE_URL}${os.EOL}`, {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
fs.appendFileSync(filePath, `GITHUB_RUN_ID=${process.env.GITHUB_RUN_ID}${os.EOL}`, {
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import * as cache from '../src/cache'
|
||||
|
||||
test('isFeatureAvailable returns true if server url is set', () => {
|
||||
try {
|
||||
process.env['ACTIONS_CACHE_URL'] = 'http://cache.com'
|
||||
expect(cache.isFeatureAvailable()).toBe(true)
|
||||
} finally {
|
||||
delete process.env['ACTIONS_CACHE_URL']
|
||||
}
|
||||
})
|
||||
|
||||
test('isFeatureAvailable returns false if server url is not set', () => {
|
||||
expect(cache.isFeatureAvailable()).toBe(false)
|
||||
})
|
||||
+17
-6
@@ -7,7 +7,7 @@ jest.mock('../src/internal/downloadUtils')
|
||||
|
||||
test('getCacheVersion with one path returns version', async () => {
|
||||
const paths = ['node_modules']
|
||||
const result = getCacheVersion(paths)
|
||||
const result = getCacheVersion(paths, undefined, true)
|
||||
expect(result).toEqual(
|
||||
'b3e0c6cb5ecf32614eeb2997d905b9c297046d7cbf69062698f25b14b4cb0985'
|
||||
)
|
||||
@@ -15,7 +15,7 @@ test('getCacheVersion with one path returns version', async () => {
|
||||
|
||||
test('getCacheVersion with multiple paths returns version', async () => {
|
||||
const paths = ['node_modules', 'dist']
|
||||
const result = getCacheVersion(paths)
|
||||
const result = getCacheVersion(paths, undefined, true)
|
||||
expect(result).toEqual(
|
||||
'165c3053bc646bf0d4fac17b1f5731caca6fe38e0e464715c0c3c6b6318bf436'
|
||||
)
|
||||
@@ -23,22 +23,33 @@ test('getCacheVersion with multiple paths returns version', async () => {
|
||||
|
||||
test('getCacheVersion with zstd compression returns version', async () => {
|
||||
const paths = ['node_modules']
|
||||
const result = getCacheVersion(paths, CompressionMethod.Zstd)
|
||||
const result = getCacheVersion(paths, CompressionMethod.Zstd, true)
|
||||
|
||||
expect(result).toEqual(
|
||||
'273877e14fd65d270b87a198edbfa2db5a43de567c9a548d2a2505b408befe24'
|
||||
)
|
||||
})
|
||||
|
||||
test('getCacheVersion with gzip compression does not change vesion', async () => {
|
||||
test('getCacheVersion with gzip compression returns version', async () => {
|
||||
const paths = ['node_modules']
|
||||
const result = getCacheVersion(paths, CompressionMethod.Gzip)
|
||||
const result = getCacheVersion(paths, CompressionMethod.Gzip, true)
|
||||
|
||||
expect(result).toEqual(
|
||||
'b3e0c6cb5ecf32614eeb2997d905b9c297046d7cbf69062698f25b14b4cb0985'
|
||||
'470e252814dbffc9524891b17cf4e5749b26c1b5026e63dd3f00972db2393117'
|
||||
)
|
||||
})
|
||||
|
||||
test('getCacheVersion with enableCrossOsArchive as false returns version on windows', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
const paths = ['node_modules']
|
||||
const result = getCacheVersion(paths)
|
||||
|
||||
expect(result).toEqual(
|
||||
'2db19d6596dc34f51f0043120148827a264863f5c6ac857569c2af7119bad14e'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('downloadCache uses http-client for non-Azure URLs', async () => {
|
||||
const downloadCacheHttpClientMock = jest.spyOn(
|
||||
downloadUtils,
|
||||
|
||||
+8
-2
@@ -2,10 +2,10 @@ import {promises as fs} from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as cacheUtils from '../src/internal/cacheUtils'
|
||||
|
||||
test('getArchiveFileSizeIsBytes returns file size', () => {
|
||||
test('getArchiveFileSizeInBytes returns file size', () => {
|
||||
const filePath = path.join(__dirname, '__fixtures__', 'helloWorld.txt')
|
||||
|
||||
const size = cacheUtils.getArchiveFileSizeIsBytes(filePath)
|
||||
const size = cacheUtils.getArchiveFileSizeInBytes(filePath)
|
||||
|
||||
expect(size).toBe(11)
|
||||
})
|
||||
@@ -32,3 +32,9 @@ test('assertDefined throws if undefined', () => {
|
||||
test('assertDefined returns value', () => {
|
||||
expect(cacheUtils.assertDefined('test', 5)).toBe(5)
|
||||
})
|
||||
|
||||
test('resolvePaths works on github workspace directory', async () => {
|
||||
const workspace = process.env['GITHUB_WORKSPACE'] ?? '.'
|
||||
const paths = await cacheUtils.resolvePaths([workspace])
|
||||
expect(paths.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
+4
-3
@@ -87,7 +87,7 @@ test('download progress tracked correctly', () => {
|
||||
expect(progress.isDone()).toBe(true)
|
||||
})
|
||||
|
||||
test('display timer works correctly', () => {
|
||||
test('display timer works correctly', done => {
|
||||
const progress = new DownloadProgress(1000)
|
||||
|
||||
const infoMock = jest.spyOn(core, 'info')
|
||||
@@ -103,6 +103,7 @@ test('display timer works correctly', () => {
|
||||
const test2 = (): void => {
|
||||
check()
|
||||
expect(progress.timeoutHandle).toBeUndefined()
|
||||
done()
|
||||
}
|
||||
|
||||
// Validate the progress is displayed, stop the timer, and call test2.
|
||||
@@ -112,7 +113,7 @@ test('display timer works correctly', () => {
|
||||
progress.stopDisplayTimer()
|
||||
progress.setReceivedBytes(1000)
|
||||
|
||||
setTimeout(() => test2(), 100)
|
||||
setTimeout(() => test2(), 500)
|
||||
}
|
||||
|
||||
// Start the timer, update the received bytes, and call test1.
|
||||
@@ -122,7 +123,7 @@ test('display timer works correctly', () => {
|
||||
|
||||
progress.setReceivedBytes(500)
|
||||
|
||||
setTimeout(() => test1(), 100)
|
||||
setTimeout(() => test1(), 500)
|
||||
}
|
||||
|
||||
start()
|
||||
|
||||
+28
-2
@@ -8,6 +8,8 @@ import {
|
||||
const useAzureSdk = true
|
||||
const downloadConcurrency = 8
|
||||
const timeoutInMs = 30000
|
||||
const segmentTimeoutInMs = 600000
|
||||
const lookupOnly = false
|
||||
const uploadConcurrency = 4
|
||||
const uploadChunkSize = 32 * 1024 * 1024
|
||||
|
||||
@@ -17,7 +19,9 @@ test('getDownloadOptions sets defaults', async () => {
|
||||
expect(actualOptions).toEqual({
|
||||
useAzureSdk,
|
||||
downloadConcurrency,
|
||||
timeoutInMs
|
||||
timeoutInMs,
|
||||
segmentTimeoutInMs,
|
||||
lookupOnly
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,7 +29,9 @@ test('getDownloadOptions overrides all settings', async () => {
|
||||
const expectedOptions: DownloadOptions = {
|
||||
useAzureSdk: false,
|
||||
downloadConcurrency: 14,
|
||||
timeoutInMs: 20000
|
||||
timeoutInMs: 20000,
|
||||
segmentTimeoutInMs: 3600000,
|
||||
lookupOnly: true
|
||||
}
|
||||
|
||||
const actualOptions = getDownloadOptions(expectedOptions)
|
||||
@@ -52,3 +58,23 @@ test('getUploadOptions overrides all settings', async () => {
|
||||
|
||||
expect(actualOptions).toEqual(expectedOptions)
|
||||
})
|
||||
|
||||
test('getDownloadOptions overrides download timeout minutes', async () => {
|
||||
const expectedOptions: DownloadOptions = {
|
||||
useAzureSdk: false,
|
||||
downloadConcurrency: 14,
|
||||
timeoutInMs: 20000,
|
||||
segmentTimeoutInMs: 3600000,
|
||||
lookupOnly: true
|
||||
}
|
||||
process.env.SEGMENT_DOWNLOAD_TIMEOUT_MINS = '10'
|
||||
const actualOptions = getDownloadOptions(expectedOptions)
|
||||
|
||||
expect(actualOptions.useAzureSdk).toEqual(expectedOptions.useAzureSdk)
|
||||
expect(actualOptions.downloadConcurrency).toEqual(
|
||||
expectedOptions.downloadConcurrency
|
||||
)
|
||||
expect(actualOptions.timeoutInMs).toEqual(expectedOptions.timeoutInMs)
|
||||
expect(actualOptions.segmentTimeoutInMs).toEqual(600000)
|
||||
expect(actualOptions.lookupOnly).toEqual(expectedOptions.lookupOnly)
|
||||
})
|
||||
|
||||
+33
-2
@@ -1,5 +1,6 @@
|
||||
import {retry} from '../src/internal/requestUtils'
|
||||
import {retry, retryTypedResponse} from '../src/internal/requestUtils'
|
||||
import {HttpClientError} from '@actions/http-client'
|
||||
import * as requestUtils from '../src/internal/requestUtils'
|
||||
|
||||
interface ITestResponse {
|
||||
statusCode: number
|
||||
@@ -30,7 +31,6 @@ async function handleResponse(
|
||||
response: ITestResponse | undefined
|
||||
): Promise<ITestResponse> {
|
||||
if (!response) {
|
||||
// eslint-disable-next-line no-undef
|
||||
fail('Retry method called too many times')
|
||||
}
|
||||
|
||||
@@ -146,3 +146,34 @@ test('retry converts errors to response object', async () => {
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('retryTypedResponse gives an error with error message', async () => {
|
||||
const httpClientError = new HttpClientError(
|
||||
'The cache filesize must be between 0 and 10 * 1024 * 1024 bytes',
|
||||
400
|
||||
)
|
||||
jest.spyOn(requestUtils, 'retry').mockReturnValue(
|
||||
new Promise(resolve => {
|
||||
resolve(httpClientError)
|
||||
})
|
||||
)
|
||||
try {
|
||||
await retryTypedResponse<string>(
|
||||
'reserveCache',
|
||||
async () =>
|
||||
new Promise(resolve => {
|
||||
resolve({
|
||||
statusCode: 400,
|
||||
result: '',
|
||||
headers: {},
|
||||
error: httpClientError
|
||||
})
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
expect(error).toHaveProperty(
|
||||
'message',
|
||||
'The cache filesize must be between 0 and 10 * 1024 * 1024 bytes'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
+57
-15
@@ -18,7 +18,6 @@ beforeAll(() => {
|
||||
jest.spyOn(core, 'warning').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'error').mockImplementation(() => {})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
||||
jest.spyOn(cacheUtils, 'getCacheFileName').mockImplementation(cm => {
|
||||
const actualUtils = jest.requireActual('../src/internal/cacheUtils')
|
||||
return actualUtils.getCacheFileName(cm)
|
||||
@@ -74,13 +73,17 @@ test('restore with no cache found', async () => {
|
||||
test('restore with server error should fail', async () => {
|
||||
const paths = ['node_modules']
|
||||
const key = 'node-test'
|
||||
const logWarningMock = jest.spyOn(core, 'warning')
|
||||
|
||||
jest.spyOn(cacheHttpClient, 'getCacheEntry').mockImplementation(() => {
|
||||
throw new Error('HTTP Error Occurred')
|
||||
})
|
||||
|
||||
await expect(restoreCache(paths, key)).rejects.toThrowError(
|
||||
'HTTP Error Occurred'
|
||||
const cacheKey = await restoreCache(paths, key)
|
||||
expect(cacheKey).toBe(undefined)
|
||||
expect(logWarningMock).toHaveBeenCalledTimes(1)
|
||||
expect(logWarningMock).toHaveBeenCalledWith(
|
||||
'Failed to restore: HTTP Error Occurred'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -123,8 +126,8 @@ test('restore with gzip compressed cache found', async () => {
|
||||
const downloadCacheMock = jest.spyOn(cacheHttpClient, 'downloadCache')
|
||||
|
||||
const fileSize = 142
|
||||
const getArchiveFileSizeIsBytesMock = jest
|
||||
.spyOn(cacheUtils, 'getArchiveFileSizeIsBytes')
|
||||
const getArchiveFileSizeInBytesMock = jest
|
||||
.spyOn(cacheUtils, 'getArchiveFileSizeInBytes')
|
||||
.mockReturnValue(fileSize)
|
||||
|
||||
const extractTarMock = jest.spyOn(tar, 'extractTar')
|
||||
@@ -139,7 +142,8 @@ test('restore with gzip compressed cache found', async () => {
|
||||
|
||||
expect(cacheKey).toBe(key)
|
||||
expect(getCacheMock).toHaveBeenCalledWith([key], paths, {
|
||||
compressionMethod: compression
|
||||
compressionMethod: compression,
|
||||
enableCrossOsArchive: false
|
||||
})
|
||||
expect(createTempDirectoryMock).toHaveBeenCalledTimes(1)
|
||||
expect(downloadCacheMock).toHaveBeenCalledWith(
|
||||
@@ -147,7 +151,7 @@ test('restore with gzip compressed cache found', async () => {
|
||||
archivePath,
|
||||
undefined
|
||||
)
|
||||
expect(getArchiveFileSizeIsBytesMock).toHaveBeenCalledWith(archivePath)
|
||||
expect(getArchiveFileSizeInBytesMock).toHaveBeenCalledWith(archivePath)
|
||||
|
||||
expect(extractTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compression)
|
||||
@@ -184,8 +188,8 @@ test('restore with zstd compressed cache found', async () => {
|
||||
const downloadCacheMock = jest.spyOn(cacheHttpClient, 'downloadCache')
|
||||
|
||||
const fileSize = 62915000
|
||||
const getArchiveFileSizeIsBytesMock = jest
|
||||
.spyOn(cacheUtils, 'getArchiveFileSizeIsBytes')
|
||||
const getArchiveFileSizeInBytesMock = jest
|
||||
.spyOn(cacheUtils, 'getArchiveFileSizeInBytes')
|
||||
.mockReturnValue(fileSize)
|
||||
|
||||
const extractTarMock = jest.spyOn(tar, 'extractTar')
|
||||
@@ -198,7 +202,8 @@ test('restore with zstd compressed cache found', async () => {
|
||||
|
||||
expect(cacheKey).toBe(key)
|
||||
expect(getCacheMock).toHaveBeenCalledWith([key], paths, {
|
||||
compressionMethod: compression
|
||||
compressionMethod: compression,
|
||||
enableCrossOsArchive: false
|
||||
})
|
||||
expect(createTempDirectoryMock).toHaveBeenCalledTimes(1)
|
||||
expect(downloadCacheMock).toHaveBeenCalledWith(
|
||||
@@ -206,7 +211,7 @@ test('restore with zstd compressed cache found', async () => {
|
||||
archivePath,
|
||||
undefined
|
||||
)
|
||||
expect(getArchiveFileSizeIsBytesMock).toHaveBeenCalledWith(archivePath)
|
||||
expect(getArchiveFileSizeInBytesMock).toHaveBeenCalledWith(archivePath)
|
||||
expect(infoMock).toHaveBeenCalledWith(`Cache Size: ~60 MB (62915000 B)`)
|
||||
|
||||
expect(extractTarMock).toHaveBeenCalledTimes(1)
|
||||
@@ -241,8 +246,8 @@ test('restore with cache found for restore key', async () => {
|
||||
const downloadCacheMock = jest.spyOn(cacheHttpClient, 'downloadCache')
|
||||
|
||||
const fileSize = 142
|
||||
const getArchiveFileSizeIsBytesMock = jest
|
||||
.spyOn(cacheUtils, 'getArchiveFileSizeIsBytes')
|
||||
const getArchiveFileSizeInBytesMock = jest
|
||||
.spyOn(cacheUtils, 'getArchiveFileSizeInBytes')
|
||||
.mockReturnValue(fileSize)
|
||||
|
||||
const extractTarMock = jest.spyOn(tar, 'extractTar')
|
||||
@@ -255,7 +260,8 @@ test('restore with cache found for restore key', async () => {
|
||||
|
||||
expect(cacheKey).toBe(restoreKey)
|
||||
expect(getCacheMock).toHaveBeenCalledWith([key, restoreKey], paths, {
|
||||
compressionMethod: compression
|
||||
compressionMethod: compression,
|
||||
enableCrossOsArchive: false
|
||||
})
|
||||
expect(createTempDirectoryMock).toHaveBeenCalledTimes(1)
|
||||
expect(downloadCacheMock).toHaveBeenCalledWith(
|
||||
@@ -263,10 +269,46 @@ test('restore with cache found for restore key', async () => {
|
||||
archivePath,
|
||||
undefined
|
||||
)
|
||||
expect(getArchiveFileSizeIsBytesMock).toHaveBeenCalledWith(archivePath)
|
||||
expect(getArchiveFileSizeInBytesMock).toHaveBeenCalledWith(archivePath)
|
||||
expect(infoMock).toHaveBeenCalledWith(`Cache Size: ~0 MB (142 B)`)
|
||||
|
||||
expect(extractTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compression)
|
||||
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('restore with dry run', async () => {
|
||||
const paths = ['node_modules']
|
||||
const key = 'node-test'
|
||||
const options = {lookupOnly: true}
|
||||
|
||||
const cacheEntry: ArtifactCacheEntry = {
|
||||
cacheKey: key,
|
||||
scope: 'refs/heads/main',
|
||||
archiveLocation: 'www.actionscache.test/download'
|
||||
}
|
||||
const getCacheMock = jest.spyOn(cacheHttpClient, 'getCacheEntry')
|
||||
getCacheMock.mockImplementation(async () => {
|
||||
return Promise.resolve(cacheEntry)
|
||||
})
|
||||
|
||||
const createTempDirectoryMock = jest.spyOn(cacheUtils, 'createTempDirectory')
|
||||
const downloadCacheMock = jest.spyOn(cacheHttpClient, 'downloadCache')
|
||||
|
||||
const compression = CompressionMethod.Gzip
|
||||
const getCompressionMock = jest
|
||||
.spyOn(cacheUtils, 'getCompressionMethod')
|
||||
.mockReturnValue(Promise.resolve(compression))
|
||||
|
||||
const cacheKey = await restoreCache(paths, key, undefined, options)
|
||||
|
||||
expect(cacheKey).toBe(key)
|
||||
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||
expect(getCacheMock).toHaveBeenCalledWith([key], paths, {
|
||||
compressionMethod: compression,
|
||||
enableCrossOsArchive: false
|
||||
})
|
||||
// creating a tempDir and downloading the cache are skipped
|
||||
expect(createTempDirectoryMock).toHaveBeenCalledTimes(0)
|
||||
expect(downloadCacheMock).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
+168
-28
@@ -5,6 +5,12 @@ import * as cacheHttpClient from '../src/internal/cacheHttpClient'
|
||||
import * as cacheUtils from '../src/internal/cacheUtils'
|
||||
import {CacheFilename, CompressionMethod} from '../src/internal/constants'
|
||||
import * as tar from '../src/internal/tar'
|
||||
import {TypedResponse} from '@actions/http-client/lib/interfaces'
|
||||
import {
|
||||
ReserveCacheResponse,
|
||||
ITypedResponseWithError
|
||||
} from '../src/internal/contracts'
|
||||
import {HttpClientError} from '@actions/http-client'
|
||||
|
||||
jest.mock('../src/internal/cacheHttpClient')
|
||||
jest.mock('../src/internal/cacheUtils')
|
||||
@@ -16,17 +22,13 @@ beforeAll(() => {
|
||||
jest.spyOn(core, 'info').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'warning').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'error').mockImplementation(() => {})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
||||
jest.spyOn(cacheUtils, 'getCacheFileName').mockImplementation(cm => {
|
||||
const actualUtils = jest.requireActual('../src/internal/cacheUtils')
|
||||
return actualUtils.getCacheFileName(cm)
|
||||
})
|
||||
|
||||
jest.spyOn(cacheUtils, 'resolvePaths').mockImplementation(async filePaths => {
|
||||
return filePaths.map(x => path.resolve(x))
|
||||
})
|
||||
|
||||
jest.spyOn(cacheUtils, 'createTempDirectory').mockImplementation(async () => {
|
||||
return Promise.resolve('/foo/bar')
|
||||
})
|
||||
@@ -46,18 +48,22 @@ test('save with large cache outputs should fail', async () => {
|
||||
const cachePaths = [path.resolve(filePath)]
|
||||
|
||||
const createTarMock = jest.spyOn(tar, 'createTar')
|
||||
const logWarningMock = jest.spyOn(core, 'warning')
|
||||
|
||||
const cacheSize = 6 * 1024 * 1024 * 1024 //~6GB, over the 5GB limit
|
||||
const cacheSize = 11 * 1024 * 1024 * 1024 //~11GB, over the 10GB limit
|
||||
jest
|
||||
.spyOn(cacheUtils, 'getArchiveFileSizeIsBytes')
|
||||
.spyOn(cacheUtils, 'getArchiveFileSizeInBytes')
|
||||
.mockReturnValueOnce(cacheSize)
|
||||
const compression = CompressionMethod.Gzip
|
||||
const getCompressionMock = jest
|
||||
.spyOn(cacheUtils, 'getCompressionMethod')
|
||||
.mockReturnValueOnce(Promise.resolve(compression))
|
||||
|
||||
await expect(saveCache([filePath], primaryKey)).rejects.toThrowError(
|
||||
'Cache size of ~6144 MB (6442450944 B) is over the 5GB limit, not saving cache.'
|
||||
const cacheId = await saveCache([filePath], primaryKey)
|
||||
expect(cacheId).toBe(-1)
|
||||
expect(logWarningMock).toHaveBeenCalledTimes(1)
|
||||
expect(logWarningMock).toHaveBeenCalledWith(
|
||||
'Failed to save: Cache size of ~11264 MB (11811160064 B) is over the 10GB limit, not saving cache.'
|
||||
)
|
||||
|
||||
const archiveFolder = '/foo/bar'
|
||||
@@ -71,14 +77,120 @@ test('save with large cache outputs should fail', async () => {
|
||||
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('save with reserve cache failure should fail', async () => {
|
||||
const paths = ['node_modules']
|
||||
test('save with large cache outputs should fail in GHES with error message', async () => {
|
||||
const filePath = 'node_modules'
|
||||
const primaryKey = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
|
||||
const cachePaths = [path.resolve(filePath)]
|
||||
|
||||
const createTarMock = jest.spyOn(tar, 'createTar')
|
||||
const logWarningMock = jest.spyOn(core, 'warning')
|
||||
|
||||
const cacheSize = 11 * 1024 * 1024 * 1024 //~11GB, over the 10GB limit
|
||||
jest
|
||||
.spyOn(cacheUtils, 'getArchiveFileSizeInBytes')
|
||||
.mockReturnValueOnce(cacheSize)
|
||||
const compression = CompressionMethod.Gzip
|
||||
const getCompressionMock = jest
|
||||
.spyOn(cacheUtils, 'getCompressionMethod')
|
||||
.mockReturnValueOnce(Promise.resolve(compression))
|
||||
|
||||
jest.spyOn(cacheUtils, 'isGhes').mockReturnValueOnce(true)
|
||||
|
||||
const reserveCacheMock = jest
|
||||
.spyOn(cacheHttpClient, 'reserveCache')
|
||||
.mockImplementation(async () => {
|
||||
return -1
|
||||
const response: ITypedResponseWithError<ReserveCacheResponse> = {
|
||||
statusCode: 400,
|
||||
result: null,
|
||||
headers: {},
|
||||
error: new HttpClientError(
|
||||
'The cache filesize must be between 0 and 1073741824 bytes',
|
||||
400
|
||||
)
|
||||
}
|
||||
return response
|
||||
})
|
||||
|
||||
const cacheId = await saveCache([filePath], primaryKey)
|
||||
expect(cacheId).toBe(-1)
|
||||
expect(logWarningMock).toHaveBeenCalledTimes(1)
|
||||
expect(logWarningMock).toHaveBeenCalledWith(
|
||||
'Failed to save: The cache filesize must be between 0 and 1073741824 bytes'
|
||||
)
|
||||
|
||||
const archiveFolder = '/foo/bar'
|
||||
expect(reserveCacheMock).toHaveBeenCalledTimes(1)
|
||||
expect(createTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(createTarMock).toHaveBeenCalledWith(
|
||||
archiveFolder,
|
||||
cachePaths,
|
||||
compression
|
||||
)
|
||||
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('save with large cache outputs should fail in GHES without error message', async () => {
|
||||
const filePath = 'node_modules'
|
||||
const primaryKey = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
|
||||
const cachePaths = [path.resolve(filePath)]
|
||||
|
||||
const createTarMock = jest.spyOn(tar, 'createTar')
|
||||
const logWarningMock = jest.spyOn(core, 'warning')
|
||||
|
||||
const cacheSize = 11 * 1024 * 1024 * 1024 //~11GB, over the 10GB limit
|
||||
jest
|
||||
.spyOn(cacheUtils, 'getArchiveFileSizeInBytes')
|
||||
.mockReturnValueOnce(cacheSize)
|
||||
const compression = CompressionMethod.Gzip
|
||||
const getCompressionMock = jest
|
||||
.spyOn(cacheUtils, 'getCompressionMethod')
|
||||
.mockReturnValueOnce(Promise.resolve(compression))
|
||||
|
||||
jest.spyOn(cacheUtils, 'isGhes').mockReturnValueOnce(true)
|
||||
|
||||
const reserveCacheMock = jest
|
||||
.spyOn(cacheHttpClient, 'reserveCache')
|
||||
.mockImplementation(async () => {
|
||||
const response: ITypedResponseWithError<ReserveCacheResponse> = {
|
||||
statusCode: 400,
|
||||
result: null,
|
||||
headers: {}
|
||||
}
|
||||
return response
|
||||
})
|
||||
|
||||
const cacheId = await saveCache([filePath], primaryKey)
|
||||
expect(cacheId).toBe(-1)
|
||||
expect(logWarningMock).toHaveBeenCalledTimes(1)
|
||||
expect(logWarningMock).toHaveBeenCalledWith(
|
||||
'Failed to save: Cache size of ~11264 MB (11811160064 B) is over the data cap limit, not saving cache.'
|
||||
)
|
||||
|
||||
const archiveFolder = '/foo/bar'
|
||||
expect(reserveCacheMock).toHaveBeenCalledTimes(1)
|
||||
expect(createTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(createTarMock).toHaveBeenCalledWith(
|
||||
archiveFolder,
|
||||
cachePaths,
|
||||
compression
|
||||
)
|
||||
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('save with reserve cache failure should fail', async () => {
|
||||
const paths = ['node_modules']
|
||||
const primaryKey = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
|
||||
const logInfoMock = jest.spyOn(core, 'info')
|
||||
|
||||
const reserveCacheMock = jest
|
||||
.spyOn(cacheHttpClient, 'reserveCache')
|
||||
.mockImplementation(async () => {
|
||||
const response: TypedResponse<ReserveCacheResponse> = {
|
||||
statusCode: 500,
|
||||
result: null,
|
||||
headers: {}
|
||||
}
|
||||
return response
|
||||
})
|
||||
|
||||
const createTarMock = jest.spyOn(tar, 'createTar')
|
||||
@@ -88,14 +200,20 @@ test('save with reserve cache failure should fail', async () => {
|
||||
.spyOn(cacheUtils, 'getCompressionMethod')
|
||||
.mockReturnValueOnce(Promise.resolve(compression))
|
||||
|
||||
await expect(saveCache(paths, primaryKey)).rejects.toThrowError(
|
||||
`Unable to reserve cache with key ${primaryKey}, another job may be creating this cache.`
|
||||
const cacheId = await saveCache(paths, primaryKey)
|
||||
expect(cacheId).toBe(-1)
|
||||
expect(logInfoMock).toHaveBeenCalledTimes(1)
|
||||
expect(logInfoMock).toHaveBeenCalledWith(
|
||||
`Failed to save: Unable to reserve cache with key ${primaryKey}, another job may be creating this cache. More details: undefined`
|
||||
)
|
||||
|
||||
expect(reserveCacheMock).toHaveBeenCalledTimes(1)
|
||||
expect(reserveCacheMock).toHaveBeenCalledWith(primaryKey, paths, {
|
||||
compressionMethod: compression
|
||||
cacheSize: undefined,
|
||||
compressionMethod: compression,
|
||||
enableCrossOsArchive: false
|
||||
})
|
||||
expect(createTarMock).toHaveBeenCalledTimes(0)
|
||||
expect(createTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(saveCacheMock).toHaveBeenCalledTimes(0)
|
||||
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -104,12 +222,17 @@ test('save with server error should fail', async () => {
|
||||
const filePath = 'node_modules'
|
||||
const primaryKey = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
|
||||
const cachePaths = [path.resolve(filePath)]
|
||||
|
||||
const logWarningMock = jest.spyOn(core, 'warning')
|
||||
const cacheId = 4
|
||||
const reserveCacheMock = jest
|
||||
.spyOn(cacheHttpClient, 'reserveCache')
|
||||
.mockImplementation(async () => {
|
||||
return cacheId
|
||||
const response: TypedResponse<ReserveCacheResponse> = {
|
||||
statusCode: 500,
|
||||
result: {cacheId},
|
||||
headers: {}
|
||||
}
|
||||
return response
|
||||
})
|
||||
|
||||
const createTarMock = jest.spyOn(tar, 'createTar')
|
||||
@@ -124,24 +247,26 @@ test('save with server error should fail', async () => {
|
||||
.spyOn(cacheUtils, 'getCompressionMethod')
|
||||
.mockReturnValueOnce(Promise.resolve(compression))
|
||||
|
||||
await expect(saveCache([filePath], primaryKey)).rejects.toThrowError(
|
||||
'HTTP Error Occurred'
|
||||
await saveCache([filePath], primaryKey)
|
||||
expect(logWarningMock).toHaveBeenCalledTimes(1)
|
||||
expect(logWarningMock).toHaveBeenCalledWith(
|
||||
'Failed to save: HTTP Error Occurred'
|
||||
)
|
||||
|
||||
expect(reserveCacheMock).toHaveBeenCalledTimes(1)
|
||||
expect(reserveCacheMock).toHaveBeenCalledWith(primaryKey, [filePath], {
|
||||
compressionMethod: compression
|
||||
cacheSize: undefined,
|
||||
compressionMethod: compression,
|
||||
enableCrossOsArchive: false
|
||||
})
|
||||
|
||||
const archiveFolder = '/foo/bar'
|
||||
const archiveFile = path.join(archiveFolder, CacheFilename.Zstd)
|
||||
|
||||
expect(createTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(createTarMock).toHaveBeenCalledWith(
|
||||
archiveFolder,
|
||||
cachePaths,
|
||||
compression
|
||||
)
|
||||
|
||||
expect(saveCacheMock).toHaveBeenCalledTimes(1)
|
||||
expect(saveCacheMock).toHaveBeenCalledWith(cacheId, archiveFile, undefined)
|
||||
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||
@@ -156,7 +281,12 @@ test('save with valid inputs uploads a cache', async () => {
|
||||
const reserveCacheMock = jest
|
||||
.spyOn(cacheHttpClient, 'reserveCache')
|
||||
.mockImplementation(async () => {
|
||||
return cacheId
|
||||
const response: TypedResponse<ReserveCacheResponse> = {
|
||||
statusCode: 500,
|
||||
result: {cacheId},
|
||||
headers: {}
|
||||
}
|
||||
return response
|
||||
})
|
||||
const createTarMock = jest.spyOn(tar, 'createTar')
|
||||
|
||||
@@ -170,20 +300,30 @@ test('save with valid inputs uploads a cache', async () => {
|
||||
|
||||
expect(reserveCacheMock).toHaveBeenCalledTimes(1)
|
||||
expect(reserveCacheMock).toHaveBeenCalledWith(primaryKey, [filePath], {
|
||||
compressionMethod: compression
|
||||
cacheSize: undefined,
|
||||
compressionMethod: compression,
|
||||
enableCrossOsArchive: false
|
||||
})
|
||||
|
||||
const archiveFolder = '/foo/bar'
|
||||
const archiveFile = path.join(archiveFolder, CacheFilename.Zstd)
|
||||
|
||||
expect(createTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(createTarMock).toHaveBeenCalledWith(
|
||||
archiveFolder,
|
||||
cachePaths,
|
||||
compression
|
||||
)
|
||||
|
||||
expect(saveCacheMock).toHaveBeenCalledTimes(1)
|
||||
expect(saveCacheMock).toHaveBeenCalledWith(cacheId, archiveFile, undefined)
|
||||
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('save with non existing path should not save cache', async () => {
|
||||
const path = 'node_modules'
|
||||
const primaryKey = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
|
||||
jest.spyOn(cacheUtils, 'resolvePaths').mockImplementation(async () => {
|
||||
return []
|
||||
})
|
||||
await expect(saveCache([path], primaryKey)).rejects.toThrowError(
|
||||
`Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.`
|
||||
)
|
||||
})
|
||||
|
||||
Vendored
+281
-57
@@ -1,7 +1,14 @@
|
||||
import * as exec from '@actions/exec'
|
||||
import * as io from '@actions/io'
|
||||
import * as path from 'path'
|
||||
import {CacheFilename, CompressionMethod} from '../src/internal/constants'
|
||||
import {
|
||||
CacheFilename,
|
||||
CompressionMethod,
|
||||
GnuTarPathOnWindows,
|
||||
ManifestFilename,
|
||||
SystemTarPathOnWindows,
|
||||
TarFilename
|
||||
} from '../src/internal/constants'
|
||||
import * as tar from '../src/internal/tar'
|
||||
import * as utils from '../src/internal/cacheUtils'
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -11,8 +18,11 @@ jest.mock('@actions/exec')
|
||||
jest.mock('@actions/io')
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
const IS_MAC = process.platform === 'darwin'
|
||||
|
||||
const defaultTarPath = process.platform === 'darwin' ? 'gtar' : 'tar'
|
||||
const defaultTarPath = IS_MAC ? 'gtar' : 'tar'
|
||||
|
||||
const defaultEnv = {MSYS: 'winsymlinks:nativestrict'}
|
||||
|
||||
function getTempDir(): string {
|
||||
return path.join(__dirname, '_temp', 'tar')
|
||||
@@ -27,6 +37,10 @@ beforeAll(async () => {
|
||||
await jest.requireActual('@actions/io').rmRF(getTempDir())
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env['GITHUB_WORKSPACE']
|
||||
await jest.requireActual('@actions/io').rmRF(getTempDir())
|
||||
@@ -40,26 +54,86 @@ test('zstd extract tar', async () => {
|
||||
? `${process.env['windir']}\\fakepath\\cache.tar`
|
||||
: 'cache.tar'
|
||||
const workspace = process.env['GITHUB_WORKSPACE']
|
||||
const tarPath = IS_WINDOWS ? GnuTarPathOnWindows : defaultTarPath
|
||||
|
||||
await tar.extractTar(archivePath, CompressionMethod.Zstd)
|
||||
|
||||
expect(mkdirMock).toHaveBeenCalledWith(workspace)
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
`"${defaultTarPath}"`,
|
||||
[
|
||||
'--use-compress-program',
|
||||
'zstd -d --long=30',
|
||||
`"${tarPath}"`,
|
||||
'-xf',
|
||||
IS_WINDOWS ? archivePath.replace(/\\/g, '/') : archivePath,
|
||||
'-P',
|
||||
'-C',
|
||||
IS_WINDOWS ? workspace?.replace(/\\/g, '/') : workspace
|
||||
].concat(IS_WINDOWS ? ['--force-local'] : []),
|
||||
{cwd: undefined}
|
||||
]
|
||||
.concat(IS_WINDOWS ? ['--force-local'] : [])
|
||||
.concat(IS_MAC ? ['--delay-directory-restore'] : [])
|
||||
.concat([
|
||||
'--use-compress-program',
|
||||
IS_WINDOWS ? '"zstd -d --long=30"' : 'unzstd --long=30'
|
||||
])
|
||||
.join(' '),
|
||||
undefined,
|
||||
{
|
||||
cwd: undefined,
|
||||
env: expect.objectContaining(defaultEnv)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('zstd extract tar with windows BSDtar', async () => {
|
||||
if (IS_WINDOWS) {
|
||||
const mkdirMock = jest.spyOn(io, 'mkdirP')
|
||||
const execMock = jest.spyOn(exec, 'exec')
|
||||
jest
|
||||
.spyOn(utils, 'getGnuTarPathOnWindows')
|
||||
.mockReturnValue(Promise.resolve(''))
|
||||
|
||||
const archivePath = `${process.env['windir']}\\fakepath\\cache.tar`
|
||||
const workspace = process.env['GITHUB_WORKSPACE']
|
||||
const tarPath = SystemTarPathOnWindows
|
||||
|
||||
await tar.extractTar(archivePath, CompressionMethod.Zstd)
|
||||
|
||||
expect(mkdirMock).toHaveBeenCalledWith(workspace)
|
||||
expect(execMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
expect(execMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
[
|
||||
'zstd -d --long=30 --force -o',
|
||||
TarFilename.replace(new RegExp(`\\${path.sep}`, 'g'), '/'),
|
||||
archivePath.replace(new RegExp(`\\${path.sep}`, 'g'), '/')
|
||||
].join(' '),
|
||||
undefined,
|
||||
{
|
||||
cwd: undefined,
|
||||
env: expect.objectContaining(defaultEnv)
|
||||
}
|
||||
)
|
||||
|
||||
expect(execMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
[
|
||||
`"${tarPath}"`,
|
||||
'-xf',
|
||||
TarFilename.replace(new RegExp(`\\${path.sep}`, 'g'), '/'),
|
||||
'-P',
|
||||
'-C',
|
||||
workspace?.replace(/\\/g, '/')
|
||||
].join(' '),
|
||||
undefined,
|
||||
{
|
||||
cwd: undefined,
|
||||
env: expect.objectContaining(defaultEnv)
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('gzip extract tar', async () => {
|
||||
const mkdirMock = jest.spyOn(io, 'mkdirP')
|
||||
const execMock = jest.spyOn(exec, 'exec')
|
||||
@@ -71,51 +145,58 @@ test('gzip extract tar', async () => {
|
||||
await tar.extractTar(archivePath, CompressionMethod.Gzip)
|
||||
|
||||
expect(mkdirMock).toHaveBeenCalledWith(workspace)
|
||||
const tarPath = IS_WINDOWS
|
||||
? `${process.env['windir']}\\System32\\tar.exe`
|
||||
: defaultTarPath
|
||||
const tarPath = IS_WINDOWS ? GnuTarPathOnWindows : defaultTarPath
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
`"${tarPath}"`,
|
||||
[
|
||||
'-z',
|
||||
`"${tarPath}"`,
|
||||
'-xf',
|
||||
IS_WINDOWS ? archivePath.replace(/\\/g, '/') : archivePath,
|
||||
'-P',
|
||||
'-C',
|
||||
IS_WINDOWS ? workspace?.replace(/\\/g, '/') : workspace
|
||||
],
|
||||
{cwd: undefined}
|
||||
]
|
||||
.concat(IS_WINDOWS ? ['--force-local'] : [])
|
||||
.concat(IS_MAC ? ['--delay-directory-restore'] : [])
|
||||
.concat(['-z'])
|
||||
.join(' '),
|
||||
undefined,
|
||||
{
|
||||
cwd: undefined,
|
||||
env: expect.objectContaining(defaultEnv)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('gzip extract GNU tar on windows', async () => {
|
||||
test('gzip extract GNU tar on windows with GNUtar in path', async () => {
|
||||
if (IS_WINDOWS) {
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValueOnce(false)
|
||||
|
||||
const isGnuMock = jest
|
||||
.spyOn(utils, 'isGnuTarInstalled')
|
||||
.mockReturnValue(Promise.resolve(true))
|
||||
// GNU tar present in path but not at default location
|
||||
jest
|
||||
.spyOn(utils, 'getGnuTarPathOnWindows')
|
||||
.mockReturnValue(Promise.resolve('tar'))
|
||||
const execMock = jest.spyOn(exec, 'exec')
|
||||
const archivePath = `${process.env['windir']}\\fakepath\\cache.tar`
|
||||
const workspace = process.env['GITHUB_WORKSPACE']
|
||||
|
||||
await tar.extractTar(archivePath, CompressionMethod.Gzip)
|
||||
|
||||
expect(isGnuMock).toHaveBeenCalledTimes(1)
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
`"tar"`,
|
||||
[
|
||||
'-z',
|
||||
`"tar"`,
|
||||
'-xf',
|
||||
archivePath.replace(/\\/g, '/'),
|
||||
'-P',
|
||||
'-C',
|
||||
workspace?.replace(/\\/g, '/'),
|
||||
'--force-local'
|
||||
],
|
||||
{cwd: undefined}
|
||||
'--force-local',
|
||||
'-z'
|
||||
].join(' '),
|
||||
undefined,
|
||||
{
|
||||
cwd: undefined,
|
||||
env: expect.objectContaining(defaultEnv)
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -131,27 +212,99 @@ test('zstd create tar', async () => {
|
||||
|
||||
await tar.createTar(archiveFolder, sourceDirectories, CompressionMethod.Zstd)
|
||||
|
||||
const tarPath = IS_WINDOWS ? GnuTarPathOnWindows : defaultTarPath
|
||||
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
`"${defaultTarPath}"`,
|
||||
[
|
||||
`"${tarPath}"`,
|
||||
'--posix',
|
||||
'--use-compress-program',
|
||||
'zstd -T0 --long=30',
|
||||
'-cf',
|
||||
IS_WINDOWS ? CacheFilename.Zstd.replace(/\\/g, '/') : CacheFilename.Zstd,
|
||||
'--exclude',
|
||||
IS_WINDOWS ? CacheFilename.Zstd.replace(/\\/g, '/') : CacheFilename.Zstd,
|
||||
'-P',
|
||||
'-C',
|
||||
IS_WINDOWS ? workspace?.replace(/\\/g, '/') : workspace,
|
||||
'--files-from',
|
||||
'manifest.txt'
|
||||
].concat(IS_WINDOWS ? ['--force-local'] : []),
|
||||
ManifestFilename
|
||||
]
|
||||
.concat(IS_WINDOWS ? ['--force-local'] : [])
|
||||
.concat(IS_MAC ? ['--delay-directory-restore'] : [])
|
||||
.concat([
|
||||
'--use-compress-program',
|
||||
IS_WINDOWS ? '"zstd -T0 --long=30"' : 'zstdmt --long=30'
|
||||
])
|
||||
.join(' '),
|
||||
undefined, // args
|
||||
{
|
||||
cwd: archiveFolder
|
||||
cwd: archiveFolder,
|
||||
env: expect.objectContaining(defaultEnv)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('zstd create tar with windows BSDtar', async () => {
|
||||
if (IS_WINDOWS) {
|
||||
const execMock = jest.spyOn(exec, 'exec')
|
||||
jest
|
||||
.spyOn(utils, 'getGnuTarPathOnWindows')
|
||||
.mockReturnValue(Promise.resolve(''))
|
||||
|
||||
const archiveFolder = getTempDir()
|
||||
const workspace = process.env['GITHUB_WORKSPACE']
|
||||
const sourceDirectories = ['~/.npm/cache', `${workspace}/dist`]
|
||||
|
||||
await fs.promises.mkdir(archiveFolder, {recursive: true})
|
||||
|
||||
await tar.createTar(
|
||||
archiveFolder,
|
||||
sourceDirectories,
|
||||
CompressionMethod.Zstd
|
||||
)
|
||||
|
||||
const tarPath = SystemTarPathOnWindows
|
||||
|
||||
expect(execMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
expect(execMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
[
|
||||
`"${tarPath}"`,
|
||||
'--posix',
|
||||
'-cf',
|
||||
TarFilename.replace(/\\/g, '/'),
|
||||
'--exclude',
|
||||
TarFilename.replace(/\\/g, '/'),
|
||||
'-P',
|
||||
'-C',
|
||||
workspace?.replace(/\\/g, '/'),
|
||||
'--files-from',
|
||||
ManifestFilename
|
||||
].join(' '),
|
||||
undefined, // args
|
||||
{
|
||||
cwd: archiveFolder,
|
||||
env: expect.objectContaining(defaultEnv)
|
||||
}
|
||||
)
|
||||
|
||||
expect(execMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
[
|
||||
'zstd -T0 --long=30 --force -o',
|
||||
CacheFilename.Zstd.replace(/\\/g, '/'),
|
||||
TarFilename.replace(/\\/g, '/')
|
||||
].join(' '),
|
||||
undefined, // args
|
||||
{
|
||||
cwd: archiveFolder,
|
||||
env: expect.objectContaining(defaultEnv)
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('gzip create tar', async () => {
|
||||
const execMock = jest.spyOn(exec, 'exec')
|
||||
|
||||
@@ -163,26 +316,31 @@ test('gzip create tar', async () => {
|
||||
|
||||
await tar.createTar(archiveFolder, sourceDirectories, CompressionMethod.Gzip)
|
||||
|
||||
const tarPath = IS_WINDOWS
|
||||
? `${process.env['windir']}\\System32\\tar.exe`
|
||||
: defaultTarPath
|
||||
const tarPath = IS_WINDOWS ? GnuTarPathOnWindows : defaultTarPath
|
||||
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
`"${tarPath}"`,
|
||||
[
|
||||
`"${tarPath}"`,
|
||||
'--posix',
|
||||
'-z',
|
||||
'-cf',
|
||||
IS_WINDOWS ? CacheFilename.Gzip.replace(/\\/g, '/') : CacheFilename.Gzip,
|
||||
'--exclude',
|
||||
IS_WINDOWS ? CacheFilename.Gzip.replace(/\\/g, '/') : CacheFilename.Gzip,
|
||||
'-P',
|
||||
'-C',
|
||||
IS_WINDOWS ? workspace?.replace(/\\/g, '/') : workspace,
|
||||
'--files-from',
|
||||
'manifest.txt'
|
||||
],
|
||||
ManifestFilename
|
||||
]
|
||||
.concat(IS_WINDOWS ? ['--force-local'] : [])
|
||||
.concat(IS_MAC ? ['--delay-directory-restore'] : [])
|
||||
.concat(['-z'])
|
||||
.join(' '),
|
||||
undefined, // args
|
||||
{
|
||||
cwd: archiveFolder
|
||||
cwd: archiveFolder,
|
||||
env: expect.objectContaining(defaultEnv)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -196,20 +354,74 @@ test('zstd list tar', async () => {
|
||||
|
||||
await tar.listTar(archivePath, CompressionMethod.Zstd)
|
||||
|
||||
const tarPath = IS_WINDOWS ? GnuTarPathOnWindows : defaultTarPath
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
`"${defaultTarPath}"`,
|
||||
[
|
||||
'--use-compress-program',
|
||||
'zstd -d --long=30',
|
||||
`"${tarPath}"`,
|
||||
'-tf',
|
||||
IS_WINDOWS ? archivePath.replace(/\\/g, '/') : archivePath,
|
||||
'-P'
|
||||
].concat(IS_WINDOWS ? ['--force-local'] : []),
|
||||
{cwd: undefined}
|
||||
]
|
||||
.concat(IS_WINDOWS ? ['--force-local'] : [])
|
||||
.concat(IS_MAC ? ['--delay-directory-restore'] : [])
|
||||
.concat([
|
||||
'--use-compress-program',
|
||||
IS_WINDOWS ? '"zstd -d --long=30"' : 'unzstd --long=30'
|
||||
])
|
||||
.join(' '),
|
||||
undefined,
|
||||
{
|
||||
cwd: undefined,
|
||||
env: expect.objectContaining(defaultEnv)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('zstd list tar with windows BSDtar', async () => {
|
||||
if (IS_WINDOWS) {
|
||||
const execMock = jest.spyOn(exec, 'exec')
|
||||
jest
|
||||
.spyOn(utils, 'getGnuTarPathOnWindows')
|
||||
.mockReturnValue(Promise.resolve(''))
|
||||
const archivePath = `${process.env['windir']}\\fakepath\\cache.tar`
|
||||
|
||||
await tar.listTar(archivePath, CompressionMethod.Zstd)
|
||||
|
||||
const tarPath = SystemTarPathOnWindows
|
||||
expect(execMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
expect(execMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
[
|
||||
'zstd -d --long=30 --force -o',
|
||||
TarFilename.replace(new RegExp(`\\${path.sep}`, 'g'), '/'),
|
||||
archivePath.replace(new RegExp(`\\${path.sep}`, 'g'), '/')
|
||||
].join(' '),
|
||||
undefined,
|
||||
{
|
||||
cwd: undefined,
|
||||
env: expect.objectContaining(defaultEnv)
|
||||
}
|
||||
)
|
||||
|
||||
expect(execMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
[
|
||||
`"${tarPath}"`,
|
||||
'-tf',
|
||||
TarFilename.replace(new RegExp(`\\${path.sep}`, 'g'), '/'),
|
||||
'-P'
|
||||
].join(' '),
|
||||
undefined,
|
||||
{
|
||||
cwd: undefined,
|
||||
env: expect.objectContaining(defaultEnv)
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('zstdWithoutLong list tar', async () => {
|
||||
const execMock = jest.spyOn(exec, 'exec')
|
||||
|
||||
@@ -219,17 +431,24 @@ test('zstdWithoutLong list tar', async () => {
|
||||
|
||||
await tar.listTar(archivePath, CompressionMethod.ZstdWithoutLong)
|
||||
|
||||
const tarPath = IS_WINDOWS ? GnuTarPathOnWindows : defaultTarPath
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
`"${defaultTarPath}"`,
|
||||
[
|
||||
'--use-compress-program',
|
||||
'zstd -d',
|
||||
`"${tarPath}"`,
|
||||
'-tf',
|
||||
IS_WINDOWS ? archivePath.replace(/\\/g, '/') : archivePath,
|
||||
'-P'
|
||||
].concat(IS_WINDOWS ? ['--force-local'] : []),
|
||||
{cwd: undefined}
|
||||
]
|
||||
.concat(IS_WINDOWS ? ['--force-local'] : [])
|
||||
.concat(IS_MAC ? ['--delay-directory-restore'] : [])
|
||||
.concat(['--use-compress-program', IS_WINDOWS ? '"zstd -d"' : 'unzstd'])
|
||||
.join(' '),
|
||||
undefined,
|
||||
{
|
||||
cwd: undefined,
|
||||
env: expect.objectContaining(defaultEnv)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -241,18 +460,23 @@ test('gzip list tar', async () => {
|
||||
|
||||
await tar.listTar(archivePath, CompressionMethod.Gzip)
|
||||
|
||||
const tarPath = IS_WINDOWS
|
||||
? `${process.env['windir']}\\System32\\tar.exe`
|
||||
: defaultTarPath
|
||||
const tarPath = IS_WINDOWS ? GnuTarPathOnWindows : defaultTarPath
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
`"${tarPath}"`,
|
||||
[
|
||||
'-z',
|
||||
`"${tarPath}"`,
|
||||
'-tf',
|
||||
IS_WINDOWS ? archivePath.replace(/\\/g, '/') : archivePath,
|
||||
'-P'
|
||||
],
|
||||
{cwd: undefined}
|
||||
]
|
||||
.concat(IS_WINDOWS ? ['--force-local'] : [])
|
||||
.concat(IS_MAC ? ['--delay-directory-restore'] : [])
|
||||
.concat(['-z'])
|
||||
.join(' '),
|
||||
undefined,
|
||||
{
|
||||
cwd: undefined,
|
||||
env: expect.objectContaining(defaultEnv)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
+730
-4403
File diff suppressed because it is too large
Load Diff
Vendored
+8
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/cache",
|
||||
"version": "1.0.6",
|
||||
"version": "3.2.1",
|
||||
"preview": true,
|
||||
"description": "Actions cache lib",
|
||||
"keywords": [
|
||||
@@ -37,19 +37,20 @@
|
||||
"url": "https://github.com/actions/toolkit/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.2.6",
|
||||
"@actions/core": "^1.10.0",
|
||||
"@actions/exec": "^1.0.1",
|
||||
"@actions/glob": "^0.1.0",
|
||||
"@actions/http-client": "^1.0.9",
|
||||
"@actions/http-client": "^2.0.1",
|
||||
"@actions/io": "^1.0.1",
|
||||
"@azure/ms-rest-js": "^2.0.7",
|
||||
"@azure/storage-blob": "^12.1.2",
|
||||
"@azure/abort-controller": "^1.1.0",
|
||||
"@azure/ms-rest-js": "^2.6.0",
|
||||
"@azure/storage-blob": "^12.13.0",
|
||||
"semver": "^6.1.0",
|
||||
"uuid": "^3.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^3.8.3",
|
||||
"@types/semver": "^6.0.0",
|
||||
"@types/uuid": "^3.4.5"
|
||||
"@types/uuid": "^3.4.5",
|
||||
"typescript": "^4.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+117
-46
@@ -43,6 +43,16 @@ function checkKey(key: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* isFeatureAvailable to check the presence of Actions cache service
|
||||
*
|
||||
* @returns boolean return true if Actions cache service feature is available, otherwise false
|
||||
*/
|
||||
|
||||
export function isFeatureAvailable(): boolean {
|
||||
return !!process.env['ACTIONS_CACHE_URL']
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores cache from keys
|
||||
*
|
||||
@@ -50,13 +60,15 @@ function checkKey(key: string): void {
|
||||
* @param primaryKey an explicit key for restoring the cache
|
||||
* @param restoreKeys an optional ordered list of keys to use for restoring the cache if no cache hit occurred for key
|
||||
* @param downloadOptions cache download options
|
||||
* @param enableCrossOsArchive an optional boolean enabled to restore on windows any cache created on any platform
|
||||
* @returns string returns the key for the cache hit, otherwise returns undefined
|
||||
*/
|
||||
export async function restoreCache(
|
||||
paths: string[],
|
||||
primaryKey: string,
|
||||
restoreKeys?: string[],
|
||||
options?: DownloadOptions
|
||||
options?: DownloadOptions,
|
||||
enableCrossOsArchive = false
|
||||
): Promise<string | undefined> {
|
||||
checkPaths(paths)
|
||||
|
||||
@@ -76,23 +88,29 @@ export async function restoreCache(
|
||||
}
|
||||
|
||||
const compressionMethod = await utils.getCompressionMethod()
|
||||
|
||||
// path are needed to compute version
|
||||
const cacheEntry = await cacheHttpClient.getCacheEntry(keys, paths, {
|
||||
compressionMethod
|
||||
})
|
||||
if (!cacheEntry?.archiveLocation) {
|
||||
// Cache not found
|
||||
return undefined
|
||||
}
|
||||
|
||||
const archivePath = path.join(
|
||||
await utils.createTempDirectory(),
|
||||
utils.getCacheFileName(compressionMethod)
|
||||
)
|
||||
core.debug(`Archive Path: ${archivePath}`)
|
||||
|
||||
let archivePath = ''
|
||||
try {
|
||||
// path are needed to compute version
|
||||
const cacheEntry = await cacheHttpClient.getCacheEntry(keys, paths, {
|
||||
compressionMethod,
|
||||
enableCrossOsArchive
|
||||
})
|
||||
if (!cacheEntry?.archiveLocation) {
|
||||
// Cache not found
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (options?.lookupOnly) {
|
||||
core.info('Lookup only - skipping download')
|
||||
return cacheEntry.cacheKey
|
||||
}
|
||||
|
||||
archivePath = path.join(
|
||||
await utils.createTempDirectory(),
|
||||
utils.getCacheFileName(compressionMethod)
|
||||
)
|
||||
core.debug(`Archive Path: ${archivePath}`)
|
||||
|
||||
// Download the cache from the cache entry
|
||||
await cacheHttpClient.downloadCache(
|
||||
cacheEntry.archiveLocation,
|
||||
@@ -104,7 +122,7 @@ export async function restoreCache(
|
||||
await listTar(archivePath, compressionMethod)
|
||||
}
|
||||
|
||||
const archiveFileSize = utils.getArchiveFileSizeIsBytes(archivePath)
|
||||
const archiveFileSize = utils.getArchiveFileSizeInBytes(archivePath)
|
||||
core.info(
|
||||
`Cache Size: ~${Math.round(
|
||||
archiveFileSize / (1024 * 1024)
|
||||
@@ -113,6 +131,16 @@ export async function restoreCache(
|
||||
|
||||
await extractTar(archivePath, compressionMethod)
|
||||
core.info('Cache restored successfully')
|
||||
|
||||
return cacheEntry.cacheKey
|
||||
} catch (error) {
|
||||
const typedError = error as Error
|
||||
if (typedError.name === ValidationError.name) {
|
||||
throw error
|
||||
} else {
|
||||
// Supress all non-validation cache related errors because caching should be optional
|
||||
core.warning(`Failed to restore: ${(error as Error).message}`)
|
||||
}
|
||||
} finally {
|
||||
// Try to delete the archive to save space
|
||||
try {
|
||||
@@ -122,7 +150,7 @@ export async function restoreCache(
|
||||
}
|
||||
}
|
||||
|
||||
return cacheEntry.cacheKey
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,34 +158,32 @@ export async function restoreCache(
|
||||
*
|
||||
* @param paths a list of file paths to be cached
|
||||
* @param key an explicit key for restoring the cache
|
||||
* @param enableCrossOsArchive an optional boolean enabled to save cache on windows which could be restored on any platform
|
||||
* @param options cache upload options
|
||||
* @returns number returns cacheId if the cache was saved successfully and throws an error if save fails
|
||||
*/
|
||||
export async function saveCache(
|
||||
paths: string[],
|
||||
key: string,
|
||||
options?: UploadOptions
|
||||
options?: UploadOptions,
|
||||
enableCrossOsArchive = false
|
||||
): Promise<number> {
|
||||
checkPaths(paths)
|
||||
checkKey(key)
|
||||
|
||||
const compressionMethod = await utils.getCompressionMethod()
|
||||
|
||||
core.debug('Reserving Cache')
|
||||
const cacheId = await cacheHttpClient.reserveCache(key, paths, {
|
||||
compressionMethod
|
||||
})
|
||||
if (cacheId === -1) {
|
||||
throw new ReserveCacheError(
|
||||
`Unable to reserve cache with key ${key}, another job may be creating this cache.`
|
||||
)
|
||||
}
|
||||
core.debug(`Cache ID: ${cacheId}`)
|
||||
let cacheId = -1
|
||||
|
||||
const cachePaths = await utils.resolvePaths(paths)
|
||||
core.debug('Cache Paths:')
|
||||
core.debug(`${JSON.stringify(cachePaths)}`)
|
||||
|
||||
if (cachePaths.length === 0) {
|
||||
throw new Error(
|
||||
`Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.`
|
||||
)
|
||||
}
|
||||
|
||||
const archiveFolder = await utils.createTempDirectory()
|
||||
const archivePath = path.join(
|
||||
archiveFolder,
|
||||
@@ -166,24 +192,69 @@ export async function saveCache(
|
||||
|
||||
core.debug(`Archive Path: ${archivePath}`)
|
||||
|
||||
await createTar(archiveFolder, cachePaths, compressionMethod)
|
||||
if (core.isDebug()) {
|
||||
await listTar(archivePath, compressionMethod)
|
||||
}
|
||||
try {
|
||||
await createTar(archiveFolder, cachePaths, compressionMethod)
|
||||
if (core.isDebug()) {
|
||||
await listTar(archivePath, compressionMethod)
|
||||
}
|
||||
const fileSizeLimit = 10 * 1024 * 1024 * 1024 // 10GB per repo limit
|
||||
const archiveFileSize = utils.getArchiveFileSizeInBytes(archivePath)
|
||||
core.debug(`File Size: ${archiveFileSize}`)
|
||||
|
||||
const fileSizeLimit = 5 * 1024 * 1024 * 1024 // 5GB per repo limit
|
||||
const archiveFileSize = utils.getArchiveFileSizeIsBytes(archivePath)
|
||||
core.debug(`File Size: ${archiveFileSize}`)
|
||||
if (archiveFileSize > fileSizeLimit) {
|
||||
throw new Error(
|
||||
`Cache size of ~${Math.round(
|
||||
archiveFileSize / (1024 * 1024)
|
||||
)} MB (${archiveFileSize} B) is over the 5GB limit, not saving cache.`
|
||||
// For GHES, this check will take place in ReserveCache API with enterprise file size limit
|
||||
if (archiveFileSize > fileSizeLimit && !utils.isGhes()) {
|
||||
throw new Error(
|
||||
`Cache size of ~${Math.round(
|
||||
archiveFileSize / (1024 * 1024)
|
||||
)} MB (${archiveFileSize} B) is over the 10GB limit, not saving cache.`
|
||||
)
|
||||
}
|
||||
|
||||
core.debug('Reserving Cache')
|
||||
const reserveCacheResponse = await cacheHttpClient.reserveCache(
|
||||
key,
|
||||
paths,
|
||||
{
|
||||
compressionMethod,
|
||||
enableCrossOsArchive,
|
||||
cacheSize: archiveFileSize
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
core.debug(`Saving Cache (ID: ${cacheId})`)
|
||||
await cacheHttpClient.saveCache(cacheId, archivePath, options)
|
||||
if (reserveCacheResponse?.result?.cacheId) {
|
||||
cacheId = reserveCacheResponse?.result?.cacheId
|
||||
} else if (reserveCacheResponse?.statusCode === 400) {
|
||||
throw new Error(
|
||||
reserveCacheResponse?.error?.message ??
|
||||
`Cache size of ~${Math.round(
|
||||
archiveFileSize / (1024 * 1024)
|
||||
)} MB (${archiveFileSize} B) is over the data cap limit, not saving cache.`
|
||||
)
|
||||
} else {
|
||||
throw new ReserveCacheError(
|
||||
`Unable to reserve cache with key ${key}, another job may be creating this cache. More details: ${reserveCacheResponse?.error?.message}`
|
||||
)
|
||||
}
|
||||
|
||||
core.debug(`Saving Cache (ID: ${cacheId})`)
|
||||
await cacheHttpClient.saveCache(cacheId, archivePath, options)
|
||||
} catch (error) {
|
||||
const typedError = error as Error
|
||||
if (typedError.name === ValidationError.name) {
|
||||
throw error
|
||||
} else if (typedError.name === ReserveCacheError.name) {
|
||||
core.info(`Failed to save: ${typedError.message}`)
|
||||
} else {
|
||||
core.warning(`Failed to save: ${typedError.message}`)
|
||||
}
|
||||
} finally {
|
||||
// Try to delete the archive to save space
|
||||
try {
|
||||
await utils.unlinkFile(archivePath)
|
||||
} catch (error) {
|
||||
core.debug(`Failed to delete archive: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
return cacheId
|
||||
}
|
||||
|
||||
+77
-32
@@ -1,7 +1,10 @@
|
||||
import * as core from '@actions/core'
|
||||
import {HttpClient} from '@actions/http-client'
|
||||
import {BearerCredentialHandler} from '@actions/http-client/auth'
|
||||
import {IRequestOptions, ITypedResponse} from '@actions/http-client/interfaces'
|
||||
import {BearerCredentialHandler} from '@actions/http-client/lib/auth'
|
||||
import {
|
||||
RequestOptions,
|
||||
TypedResponse
|
||||
} from '@actions/http-client/lib/interfaces'
|
||||
import * as crypto from 'crypto'
|
||||
import * as fs from 'fs'
|
||||
import {URL} from 'url'
|
||||
@@ -13,7 +16,9 @@ import {
|
||||
InternalCacheOptions,
|
||||
CommitCacheRequest,
|
||||
ReserveCacheRequest,
|
||||
ReserveCacheResponse
|
||||
ReserveCacheResponse,
|
||||
ITypedResponseWithError,
|
||||
ArtifactCacheList
|
||||
} from './contracts'
|
||||
import {downloadCacheHttpClient, downloadCacheStorageSDK} from './downloadUtils'
|
||||
import {
|
||||
@@ -31,12 +36,7 @@ import {
|
||||
const versionSalt = '1.0'
|
||||
|
||||
function getCacheApiUrl(resource: string): string {
|
||||
// Ideally we just use ACTIONS_CACHE_URL
|
||||
const baseUrl: string = (
|
||||
process.env['ACTIONS_CACHE_URL'] ||
|
||||
process.env['ACTIONS_RUNTIME_URL'] ||
|
||||
''
|
||||
).replace('pipelines', 'artifactcache')
|
||||
const baseUrl: string = process.env['ACTIONS_CACHE_URL'] || ''
|
||||
if (!baseUrl) {
|
||||
throw new Error('Cache Service Url not found, unable to restore cache.')
|
||||
}
|
||||
@@ -50,8 +50,8 @@ function createAcceptHeader(type: string, apiVersion: string): string {
|
||||
return `${type};api-version=${apiVersion}`
|
||||
}
|
||||
|
||||
function getRequestOptions(): IRequestOptions {
|
||||
const requestOptions: IRequestOptions = {
|
||||
function getRequestOptions(): RequestOptions {
|
||||
const requestOptions: RequestOptions = {
|
||||
headers: {
|
||||
Accept: createAcceptHeader('application/json', '6.0-preview.1')
|
||||
}
|
||||
@@ -73,21 +73,26 @@ function createHttpClient(): HttpClient {
|
||||
|
||||
export function getCacheVersion(
|
||||
paths: string[],
|
||||
compressionMethod?: CompressionMethod
|
||||
compressionMethod?: CompressionMethod,
|
||||
enableCrossOsArchive = false
|
||||
): string {
|
||||
const components = paths.concat(
|
||||
!compressionMethod || compressionMethod === CompressionMethod.Gzip
|
||||
? []
|
||||
: [compressionMethod]
|
||||
)
|
||||
const components = paths
|
||||
|
||||
// Add compression method to cache version to restore
|
||||
// compressed cache as per compression method
|
||||
if (compressionMethod) {
|
||||
components.push(compressionMethod)
|
||||
}
|
||||
|
||||
// Only check for windows platforms if enableCrossOsArchive is false
|
||||
if (process.platform === 'win32' && !enableCrossOsArchive) {
|
||||
components.push('windows-only')
|
||||
}
|
||||
|
||||
// Add salt to cache version to support breaking changes in cache entry
|
||||
components.push(versionSalt)
|
||||
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(components.join('|'))
|
||||
.digest('hex')
|
||||
return crypto.createHash('sha256').update(components.join('|')).digest('hex')
|
||||
}
|
||||
|
||||
export async function getCacheEntry(
|
||||
@@ -96,7 +101,11 @@ export async function getCacheEntry(
|
||||
options?: InternalCacheOptions
|
||||
): Promise<ArtifactCacheEntry | null> {
|
||||
const httpClient = createHttpClient()
|
||||
const version = getCacheVersion(paths, options?.compressionMethod)
|
||||
const version = getCacheVersion(
|
||||
paths,
|
||||
options?.compressionMethod,
|
||||
options?.enableCrossOsArchive
|
||||
)
|
||||
const resource = `cache?keys=${encodeURIComponent(
|
||||
keys.join(',')
|
||||
)}&version=${version}`
|
||||
@@ -104,7 +113,12 @@ export async function getCacheEntry(
|
||||
const response = await retryTypedResponse('getCacheEntry', async () =>
|
||||
httpClient.getJson<ArtifactCacheEntry>(getCacheApiUrl(resource))
|
||||
)
|
||||
// Cache not found
|
||||
if (response.statusCode === 204) {
|
||||
// List cache for primary key only if cache miss occurs
|
||||
if (core.isDebug()) {
|
||||
await printCachesListForDiagnostics(keys[0], httpClient, version)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (!isSuccessStatusCode(response.statusCode)) {
|
||||
@@ -114,6 +128,7 @@ export async function getCacheEntry(
|
||||
const cacheResult = response.result
|
||||
const cacheDownloadUrl = cacheResult?.archiveLocation
|
||||
if (!cacheDownloadUrl) {
|
||||
// Cache achiveLocation not found. This should never happen, and hence bail out.
|
||||
throw new Error('Cache not found.')
|
||||
}
|
||||
core.setSecret(cacheDownloadUrl)
|
||||
@@ -123,6 +138,31 @@ export async function getCacheEntry(
|
||||
return cacheResult
|
||||
}
|
||||
|
||||
async function printCachesListForDiagnostics(
|
||||
key: string,
|
||||
httpClient: HttpClient,
|
||||
version: string
|
||||
): Promise<void> {
|
||||
const resource = `caches?key=${encodeURIComponent(key)}`
|
||||
const response = await retryTypedResponse('listCache', async () =>
|
||||
httpClient.getJson<ArtifactCacheList>(getCacheApiUrl(resource))
|
||||
)
|
||||
if (response.statusCode === 200) {
|
||||
const cacheListResult = response.result
|
||||
const totalCount = cacheListResult?.totalCount
|
||||
if (totalCount && totalCount > 0) {
|
||||
core.debug(
|
||||
`No matching cache found for cache key '${key}', version '${version} and scope ${process.env['GITHUB_REF']}. There exist one or more cache(s) with similar key but they have different version or scope. See more info on cache matching here: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#matching-a-cache-key \nOther caches with similar key:`
|
||||
)
|
||||
for (const cacheEntry of cacheListResult?.artifactCaches || []) {
|
||||
core.debug(
|
||||
`Cache Key: ${cacheEntry?.cacheKey}, Cache Version: ${cacheEntry?.cacheVersion}, Cache Scope: ${cacheEntry?.scope}, Cache Created: ${cacheEntry?.creationTime}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadCache(
|
||||
archiveLocation: string,
|
||||
archivePath: string,
|
||||
@@ -148,13 +188,18 @@ export async function reserveCache(
|
||||
key: string,
|
||||
paths: string[],
|
||||
options?: InternalCacheOptions
|
||||
): Promise<number> {
|
||||
): Promise<ITypedResponseWithError<ReserveCacheResponse>> {
|
||||
const httpClient = createHttpClient()
|
||||
const version = getCacheVersion(paths, options?.compressionMethod)
|
||||
const version = getCacheVersion(
|
||||
paths,
|
||||
options?.compressionMethod,
|
||||
options?.enableCrossOsArchive
|
||||
)
|
||||
|
||||
const reserveCacheRequest: ReserveCacheRequest = {
|
||||
key,
|
||||
version
|
||||
version,
|
||||
cacheSize: options?.cacheSize
|
||||
}
|
||||
const response = await retryTypedResponse('reserveCache', async () =>
|
||||
httpClient.postJson<ReserveCacheResponse>(
|
||||
@@ -162,7 +207,7 @@ export async function reserveCache(
|
||||
reserveCacheRequest
|
||||
)
|
||||
)
|
||||
return response?.result?.cacheId ?? -1
|
||||
return response
|
||||
}
|
||||
|
||||
function getContentRange(start: number, end: number): string {
|
||||
@@ -182,9 +227,9 @@ async function uploadChunk(
|
||||
end: number
|
||||
): Promise<void> {
|
||||
core.debug(
|
||||
`Uploading chunk of size ${end -
|
||||
start +
|
||||
1} bytes at offset ${start} with content range: ${getContentRange(
|
||||
`Uploading chunk of size ${
|
||||
end - start + 1
|
||||
} bytes at offset ${start} with content range: ${getContentRange(
|
||||
start,
|
||||
end
|
||||
)}`
|
||||
@@ -219,7 +264,7 @@ async function uploadFile(
|
||||
options?: UploadOptions
|
||||
): Promise<void> {
|
||||
// Upload Chunks
|
||||
const fileSize = fs.statSync(archivePath).size
|
||||
const fileSize = utils.getArchiveFileSizeInBytes(archivePath)
|
||||
const resourceUrl = getCacheApiUrl(`caches/${cacheId.toString()}`)
|
||||
const fd = fs.openSync(archivePath, 'r')
|
||||
const uploadOptions = getUploadOptions(options)
|
||||
@@ -278,7 +323,7 @@ async function commitCache(
|
||||
httpClient: HttpClient,
|
||||
cacheId: number,
|
||||
filesize: number
|
||||
): Promise<ITypedResponse<null>> {
|
||||
): Promise<TypedResponse<null>> {
|
||||
const commitCacheRequest: CommitCacheRequest = {size: filesize}
|
||||
return await retryTypedResponse('commitCache', async () =>
|
||||
httpClient.postJson<null>(
|
||||
@@ -300,7 +345,7 @@ export async function saveCache(
|
||||
|
||||
// Commit Cache
|
||||
core.debug('Commiting cache')
|
||||
const cacheSize = utils.getArchiveFileSizeIsBytes(archivePath)
|
||||
const cacheSize = utils.getArchiveFileSizeInBytes(archivePath)
|
||||
core.info(
|
||||
`Cache Size: ~${Math.round(cacheSize / (1024 * 1024))} MB (${cacheSize} B)`
|
||||
)
|
||||
|
||||
+35
-21
@@ -7,7 +7,11 @@ import * as path from 'path'
|
||||
import * as semver from 'semver'
|
||||
import * as util from 'util'
|
||||
import {v4 as uuidV4} from 'uuid'
|
||||
import {CacheFilename, CompressionMethod} from './constants'
|
||||
import {
|
||||
CacheFilename,
|
||||
CompressionMethod,
|
||||
GnuTarPathOnWindows
|
||||
} from './constants'
|
||||
|
||||
// From https://github.com/actions/toolkit/blob/main/packages/tool-cache/src/tool-cache.ts#L23
|
||||
export async function createTempDirectory(): Promise<string> {
|
||||
@@ -35,7 +39,7 @@ export async function createTempDirectory(): Promise<string> {
|
||||
return dest
|
||||
}
|
||||
|
||||
export function getArchiveFileSizeIsBytes(filePath: string): number {
|
||||
export function getArchiveFileSizeInBytes(filePath: string): number {
|
||||
return fs.statSync(filePath).size
|
||||
}
|
||||
|
||||
@@ -52,7 +56,12 @@ export async function resolvePaths(patterns: string[]): Promise<string[]> {
|
||||
.replace(new RegExp(`\\${path.sep}`, 'g'), '/')
|
||||
core.debug(`Matched: ${relativeFile}`)
|
||||
// Paths are made relative so the tar entries are all relative to the root of the workspace.
|
||||
paths.push(`${relativeFile}`)
|
||||
if (relativeFile === '') {
|
||||
// path.relative returns empty string if workspace and file are equal
|
||||
paths.push('.')
|
||||
} else {
|
||||
paths.push(`${relativeFile}`)
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
@@ -62,11 +71,15 @@ export async function unlinkFile(filePath: fs.PathLike): Promise<void> {
|
||||
return util.promisify(fs.unlink)(filePath)
|
||||
}
|
||||
|
||||
async function getVersion(app: string): Promise<string> {
|
||||
core.debug(`Checking ${app} --version`)
|
||||
async function getVersion(
|
||||
app: string,
|
||||
additionalArgs: string[] = []
|
||||
): Promise<string> {
|
||||
let versionOutput = ''
|
||||
additionalArgs.push('--version')
|
||||
core.debug(`Checking ${app} ${additionalArgs.join(' ')}`)
|
||||
try {
|
||||
await exec.exec(`${app} --version`, [], {
|
||||
await exec.exec(`${app}`, additionalArgs, {
|
||||
ignoreReturnCode: true,
|
||||
silent: true,
|
||||
listeners: {
|
||||
@@ -85,23 +98,14 @@ async function getVersion(app: string): Promise<string> {
|
||||
|
||||
// Use zstandard if possible to maximize cache performance
|
||||
export async function getCompressionMethod(): Promise<CompressionMethod> {
|
||||
if (process.platform === 'win32' && !(await isGnuTarInstalled())) {
|
||||
// Disable zstd due to bug https://github.com/actions/cache/issues/301
|
||||
return CompressionMethod.Gzip
|
||||
}
|
||||
|
||||
const versionOutput = await getVersion('zstd')
|
||||
const versionOutput = await getVersion('zstd', ['--quiet'])
|
||||
const version = semver.clean(versionOutput)
|
||||
core.debug(`zstd version: ${version}`)
|
||||
|
||||
if (!versionOutput.toLowerCase().includes('zstd command line interface')) {
|
||||
// zstd is not installed
|
||||
if (versionOutput === '') {
|
||||
return CompressionMethod.Gzip
|
||||
} else if (!version || semver.lt(version, 'v1.3.2')) {
|
||||
// zstd is installed but using a version earlier than v1.3.2
|
||||
// v1.3.2 is required to use the `--long` options in zstd
|
||||
return CompressionMethod.ZstdWithoutLong
|
||||
} else {
|
||||
return CompressionMethod.Zstd
|
||||
return CompressionMethod.ZstdWithoutLong
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,9 +115,12 @@ export function getCacheFileName(compressionMethod: CompressionMethod): string {
|
||||
: CacheFilename.Zstd
|
||||
}
|
||||
|
||||
export async function isGnuTarInstalled(): Promise<boolean> {
|
||||
export async function getGnuTarPathOnWindows(): Promise<string> {
|
||||
if (fs.existsSync(GnuTarPathOnWindows)) {
|
||||
return GnuTarPathOnWindows
|
||||
}
|
||||
const versionOutput = await getVersion('tar')
|
||||
return versionOutput.toLowerCase().includes('gnu tar')
|
||||
return versionOutput.toLowerCase().includes('gnu tar') ? io.which('tar') : ''
|
||||
}
|
||||
|
||||
export function assertDefined<T>(name: string, value?: T): T {
|
||||
@@ -123,3 +130,10 @@ export function assertDefined<T>(name: string, value?: T): T {
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export function isGhes(): boolean {
|
||||
const ghUrl = new URL(
|
||||
process.env['GITHUB_SERVER_URL'] || 'https://github.com'
|
||||
)
|
||||
return ghUrl.hostname.toUpperCase() !== 'GITHUB.COM'
|
||||
}
|
||||
|
||||
+15
@@ -11,6 +11,11 @@ export enum CompressionMethod {
|
||||
Zstd = 'zstd'
|
||||
}
|
||||
|
||||
export enum ArchiveToolType {
|
||||
GNU = 'gnu',
|
||||
BSD = 'bsd'
|
||||
}
|
||||
|
||||
// The default number of retry attempts.
|
||||
export const DefaultRetryAttempts = 2
|
||||
|
||||
@@ -21,3 +26,13 @@ export const DefaultRetryDelay = 5000
|
||||
// over the socket during this period, the socket is destroyed and the download
|
||||
// is aborted.
|
||||
export const SocketTimeout = 5000
|
||||
|
||||
// The default path of GNUtar on hosted Windows runners
|
||||
export const GnuTarPathOnWindows = `${process.env['PROGRAMFILES']}\\Git\\usr\\bin\\tar.exe`
|
||||
|
||||
// The default path of BSDtar on hosted Windows runners
|
||||
export const SystemTarPathOnWindows = `${process.env['SYSTEMDRIVE']}\\Windows\\System32\\tar.exe`
|
||||
|
||||
export const TarFilename = 'cache.tar'
|
||||
|
||||
export const ManifestFilename = 'manifest.txt'
|
||||
|
||||
+20
@@ -1,12 +1,24 @@
|
||||
import {CompressionMethod} from './constants'
|
||||
import {TypedResponse} from '@actions/http-client/lib/interfaces'
|
||||
import {HttpClientError} from '@actions/http-client'
|
||||
|
||||
export interface ITypedResponseWithError<T> extends TypedResponse<T> {
|
||||
error?: HttpClientError
|
||||
}
|
||||
|
||||
export interface ArtifactCacheEntry {
|
||||
cacheKey?: string
|
||||
scope?: string
|
||||
cacheVersion?: string
|
||||
creationTime?: string
|
||||
archiveLocation?: string
|
||||
}
|
||||
|
||||
export interface ArtifactCacheList {
|
||||
totalCount: number
|
||||
artifactCaches?: ArtifactCacheEntry[]
|
||||
}
|
||||
|
||||
export interface CommitCacheRequest {
|
||||
size: number
|
||||
}
|
||||
@@ -14,6 +26,7 @@ export interface CommitCacheRequest {
|
||||
export interface ReserveCacheRequest {
|
||||
key: string
|
||||
version?: string
|
||||
cacheSize?: number
|
||||
}
|
||||
|
||||
export interface ReserveCacheResponse {
|
||||
@@ -22,4 +35,11 @@ export interface ReserveCacheResponse {
|
||||
|
||||
export interface InternalCacheOptions {
|
||||
compressionMethod?: CompressionMethod
|
||||
enableCrossOsArchive?: boolean
|
||||
cacheSize?: number
|
||||
}
|
||||
|
||||
export interface ArchiveTool {
|
||||
path: string
|
||||
type: string
|
||||
}
|
||||
|
||||
+40
-15
@@ -1,6 +1,5 @@
|
||||
import * as core from '@actions/core'
|
||||
import {HttpClient} from '@actions/http-client'
|
||||
import {IHttpClientResponse} from '@actions/http-client/interfaces'
|
||||
import {HttpClient, HttpClientResponse} from '@actions/http-client'
|
||||
import {BlockBlobClient} from '@azure/storage-blob'
|
||||
import {TransferProgressEvent} from '@azure/ms-rest-js'
|
||||
import * as buffer from 'buffer'
|
||||
@@ -13,6 +12,8 @@ import {SocketTimeout} from './constants'
|
||||
import {DownloadOptions} from '../options'
|
||||
import {retryHttpClientResponse} from './requestUtils'
|
||||
|
||||
import {AbortController} from '@azure/abort-controller'
|
||||
|
||||
/**
|
||||
* Pipes the body of a HTTP response to a stream
|
||||
*
|
||||
@@ -20,7 +21,7 @@ import {retryHttpClientResponse} from './requestUtils'
|
||||
* @param output the writable stream
|
||||
*/
|
||||
async function pipeResponseToStream(
|
||||
response: IHttpClientResponse,
|
||||
response: HttpClientResponse,
|
||||
output: NodeJS.WritableStream
|
||||
): Promise<void> {
|
||||
const pipeline = util.promisify(stream.pipeline)
|
||||
@@ -133,7 +134,7 @@ export class DownloadProgress {
|
||||
*
|
||||
* @param delayInMs the delay between each write
|
||||
*/
|
||||
startDisplayTimer(delayInMs: number = 1000): void {
|
||||
startDisplayTimer(delayInMs = 1000): void {
|
||||
const displayCallback = (): void => {
|
||||
this.display()
|
||||
|
||||
@@ -190,7 +191,7 @@ export async function downloadCacheHttpClient(
|
||||
|
||||
if (contentLengthHeader) {
|
||||
const expectedLength = parseInt(contentLengthHeader)
|
||||
const actualLength = utils.getArchiveFileSizeIsBytes(archivePath)
|
||||
const actualLength = utils.getArchiveFileSizeInBytes(archivePath)
|
||||
|
||||
if (actualLength !== expectedLength) {
|
||||
throw new Error(
|
||||
@@ -240,14 +241,18 @@ export async function downloadCacheStorageSDK(
|
||||
//
|
||||
// If the file exceeds the buffer maximum length (~1 GB on 32-bit systems and ~2 GB
|
||||
// on 64-bit systems), split the download into multiple segments
|
||||
const maxSegmentSize = buffer.constants.MAX_LENGTH
|
||||
// ~2 GB = 2147483647, beyond this, we start getting out of range error. So, capping it accordingly.
|
||||
|
||||
// Updated segment size to 128MB = 134217728 bytes, to complete a segment faster and fail fast
|
||||
const maxSegmentSize = Math.min(134217728, buffer.constants.MAX_LENGTH)
|
||||
const downloadProgress = new DownloadProgress(contentLength)
|
||||
|
||||
const fd = fs.openSync(archivePath, 'w')
|
||||
|
||||
try {
|
||||
downloadProgress.startDisplayTimer()
|
||||
|
||||
const controller = new AbortController()
|
||||
const abortSignal = controller.signal
|
||||
while (!downloadProgress.isDone()) {
|
||||
const segmentStart =
|
||||
downloadProgress.segmentOffset + downloadProgress.segmentSize
|
||||
@@ -258,17 +263,22 @@ export async function downloadCacheStorageSDK(
|
||||
)
|
||||
|
||||
downloadProgress.nextSegment(segmentSize)
|
||||
|
||||
const result = await client.downloadToBuffer(
|
||||
segmentStart,
|
||||
segmentSize,
|
||||
{
|
||||
const result = await promiseWithTimeout(
|
||||
options.segmentTimeoutInMs || 3600000,
|
||||
client.downloadToBuffer(segmentStart, segmentSize, {
|
||||
abortSignal,
|
||||
concurrency: options.downloadConcurrency,
|
||||
onProgress: downloadProgress.onProgress()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
fs.writeFileSync(fd, result)
|
||||
if (result === 'timeout') {
|
||||
controller.abort()
|
||||
throw new Error(
|
||||
'Aborting cache download as the download time exceeded the timeout.'
|
||||
)
|
||||
} else if (Buffer.isBuffer(result)) {
|
||||
fs.writeFileSync(fd, result)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
downloadProgress.stopDisplayTimer()
|
||||
@@ -276,3 +286,18 @@ export async function downloadCacheStorageSDK(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const promiseWithTimeout = async (
|
||||
timeoutMs: number,
|
||||
promise: Promise<Buffer>
|
||||
): Promise<unknown> => {
|
||||
let timeoutHandle: NodeJS.Timeout
|
||||
const timeoutPromise = new Promise(resolve => {
|
||||
timeoutHandle = setTimeout(() => resolve('timeout'), timeoutMs)
|
||||
})
|
||||
|
||||
return Promise.race([promise, timeoutPromise]).then(result => {
|
||||
clearTimeout(timeoutHandle)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
+15
-13
@@ -1,10 +1,11 @@
|
||||
import * as core from '@actions/core'
|
||||
import {HttpCodes, HttpClientError} from '@actions/http-client'
|
||||
import {
|
||||
IHttpClientResponse,
|
||||
ITypedResponse
|
||||
} from '@actions/http-client/interfaces'
|
||||
HttpCodes,
|
||||
HttpClientError,
|
||||
HttpClientResponse
|
||||
} from '@actions/http-client'
|
||||
import {DefaultRetryDelay, DefaultRetryAttempts} from './constants'
|
||||
import {ITypedResponseWithError} from './contracts'
|
||||
|
||||
export function isSuccessStatusCode(statusCode?: number): boolean {
|
||||
if (!statusCode) {
|
||||
@@ -94,24 +95,25 @@ export async function retry<T>(
|
||||
|
||||
export async function retryTypedResponse<T>(
|
||||
name: string,
|
||||
method: () => Promise<ITypedResponse<T>>,
|
||||
method: () => Promise<ITypedResponseWithError<T>>,
|
||||
maxAttempts = DefaultRetryAttempts,
|
||||
delay = DefaultRetryDelay
|
||||
): Promise<ITypedResponse<T>> {
|
||||
): Promise<ITypedResponseWithError<T>> {
|
||||
return await retry(
|
||||
name,
|
||||
method,
|
||||
(response: ITypedResponse<T>) => response.statusCode,
|
||||
(response: ITypedResponseWithError<T>) => response.statusCode,
|
||||
maxAttempts,
|
||||
delay,
|
||||
// If the error object contains the statusCode property, extract it and return
|
||||
// an ITypedResponse<T> so it can be processed by the retry logic.
|
||||
// an TypedResponse<T> so it can be processed by the retry logic.
|
||||
(error: Error) => {
|
||||
if (error instanceof HttpClientError) {
|
||||
return {
|
||||
statusCode: error.statusCode,
|
||||
result: null,
|
||||
headers: {}
|
||||
headers: {},
|
||||
error
|
||||
}
|
||||
} else {
|
||||
return undefined
|
||||
@@ -120,16 +122,16 @@ export async function retryTypedResponse<T>(
|
||||
)
|
||||
}
|
||||
|
||||
export async function retryHttpClientResponse<T>(
|
||||
export async function retryHttpClientResponse(
|
||||
name: string,
|
||||
method: () => Promise<IHttpClientResponse>,
|
||||
method: () => Promise<HttpClientResponse>,
|
||||
maxAttempts = DefaultRetryAttempts,
|
||||
delay = DefaultRetryDelay
|
||||
): Promise<IHttpClientResponse> {
|
||||
): Promise<HttpClientResponse> {
|
||||
return await retry(
|
||||
name,
|
||||
method,
|
||||
(response: IHttpClientResponse) => response.message.statusCode,
|
||||
(response: HttpClientResponse) => response.message.statusCode,
|
||||
maxAttempts,
|
||||
delay
|
||||
)
|
||||
|
||||
Vendored
+248
-104
@@ -3,55 +3,273 @@ import * as io from '@actions/io'
|
||||
import {existsSync, writeFileSync} from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as utils from './cacheUtils'
|
||||
import {CompressionMethod} from './constants'
|
||||
import {ArchiveTool} from './contracts'
|
||||
import {
|
||||
CompressionMethod,
|
||||
SystemTarPathOnWindows,
|
||||
ArchiveToolType,
|
||||
TarFilename,
|
||||
ManifestFilename
|
||||
} from './constants'
|
||||
|
||||
async function getTarPath(
|
||||
args: string[],
|
||||
compressionMethod: CompressionMethod
|
||||
): Promise<string> {
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
|
||||
// Returns tar path and type: BSD or GNU
|
||||
async function getTarPath(): Promise<ArchiveTool> {
|
||||
switch (process.platform) {
|
||||
case 'win32': {
|
||||
const systemTar = `${process.env['windir']}\\System32\\tar.exe`
|
||||
if (compressionMethod !== CompressionMethod.Gzip) {
|
||||
// We only use zstandard compression on windows when gnu tar is installed due to
|
||||
// a bug with compressing large files with bsdtar + zstd
|
||||
args.push('--force-local')
|
||||
const gnuTar = await utils.getGnuTarPathOnWindows()
|
||||
const systemTar = SystemTarPathOnWindows
|
||||
if (gnuTar) {
|
||||
// Use GNUtar as default on windows
|
||||
return <ArchiveTool>{path: gnuTar, type: ArchiveToolType.GNU}
|
||||
} else if (existsSync(systemTar)) {
|
||||
return systemTar
|
||||
} else if (await utils.isGnuTarInstalled()) {
|
||||
args.push('--force-local')
|
||||
return <ArchiveTool>{path: systemTar, type: ArchiveToolType.BSD}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'darwin': {
|
||||
const gnuTar = await io.which('gtar', false)
|
||||
if (gnuTar) {
|
||||
return gnuTar
|
||||
// fix permission denied errors when extracting BSD tar archive with GNU tar - https://github.com/actions/cache/issues/527
|
||||
return <ArchiveTool>{path: gnuTar, type: ArchiveToolType.GNU}
|
||||
} else {
|
||||
return <ArchiveTool>{
|
||||
path: await io.which('tar', true),
|
||||
type: ArchiveToolType.BSD
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
return await io.which('tar', true)
|
||||
// Default assumption is GNU tar is present in path
|
||||
return <ArchiveTool>{
|
||||
path: await io.which('tar', true),
|
||||
type: ArchiveToolType.GNU
|
||||
}
|
||||
}
|
||||
|
||||
async function execTar(
|
||||
args: string[],
|
||||
// Return arguments for tar as per tarPath, compressionMethod, method type and os
|
||||
async function getTarArgs(
|
||||
tarPath: ArchiveTool,
|
||||
compressionMethod: CompressionMethod,
|
||||
cwd?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await exec(`"${await getTarPath(args, compressionMethod)}"`, args, {cwd})
|
||||
} catch (error) {
|
||||
throw new Error(`Tar failed with error: ${error?.message}`)
|
||||
type: string,
|
||||
archivePath = ''
|
||||
): Promise<string[]> {
|
||||
const args = [`"${tarPath.path}"`]
|
||||
const cacheFileName = utils.getCacheFileName(compressionMethod)
|
||||
const tarFile = 'cache.tar'
|
||||
const workingDirectory = getWorkingDirectory()
|
||||
// Speficic args for BSD tar on windows for workaround
|
||||
const BSD_TAR_ZSTD =
|
||||
tarPath.type === ArchiveToolType.BSD &&
|
||||
compressionMethod !== CompressionMethod.Gzip &&
|
||||
IS_WINDOWS
|
||||
|
||||
// Method specific args
|
||||
switch (type) {
|
||||
case 'create':
|
||||
args.push(
|
||||
'--posix',
|
||||
'-cf',
|
||||
BSD_TAR_ZSTD
|
||||
? tarFile
|
||||
: cacheFileName.replace(new RegExp(`\\${path.sep}`, 'g'), '/'),
|
||||
'--exclude',
|
||||
BSD_TAR_ZSTD
|
||||
? tarFile
|
||||
: cacheFileName.replace(new RegExp(`\\${path.sep}`, 'g'), '/'),
|
||||
'-P',
|
||||
'-C',
|
||||
workingDirectory.replace(new RegExp(`\\${path.sep}`, 'g'), '/'),
|
||||
'--files-from',
|
||||
ManifestFilename
|
||||
)
|
||||
break
|
||||
case 'extract':
|
||||
args.push(
|
||||
'-xf',
|
||||
BSD_TAR_ZSTD
|
||||
? tarFile
|
||||
: archivePath.replace(new RegExp(`\\${path.sep}`, 'g'), '/'),
|
||||
'-P',
|
||||
'-C',
|
||||
workingDirectory.replace(new RegExp(`\\${path.sep}`, 'g'), '/')
|
||||
)
|
||||
break
|
||||
case 'list':
|
||||
args.push(
|
||||
'-tf',
|
||||
BSD_TAR_ZSTD
|
||||
? tarFile
|
||||
: archivePath.replace(new RegExp(`\\${path.sep}`, 'g'), '/'),
|
||||
'-P'
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
// Platform specific args
|
||||
if (tarPath.type === ArchiveToolType.GNU) {
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
args.push('--force-local')
|
||||
break
|
||||
case 'darwin':
|
||||
args.push('--delay-directory-restore')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
// Returns commands to run tar and compression program
|
||||
async function getCommands(
|
||||
compressionMethod: CompressionMethod,
|
||||
type: string,
|
||||
archivePath = ''
|
||||
): Promise<string[]> {
|
||||
let args
|
||||
|
||||
const tarPath = await getTarPath()
|
||||
const tarArgs = await getTarArgs(
|
||||
tarPath,
|
||||
compressionMethod,
|
||||
type,
|
||||
archivePath
|
||||
)
|
||||
const compressionArgs =
|
||||
type !== 'create'
|
||||
? await getDecompressionProgram(tarPath, compressionMethod, archivePath)
|
||||
: await getCompressionProgram(tarPath, compressionMethod)
|
||||
const BSD_TAR_ZSTD =
|
||||
tarPath.type === ArchiveToolType.BSD &&
|
||||
compressionMethod !== CompressionMethod.Gzip &&
|
||||
IS_WINDOWS
|
||||
|
||||
if (BSD_TAR_ZSTD && type !== 'create') {
|
||||
args = [[...compressionArgs].join(' '), [...tarArgs].join(' ')]
|
||||
} else {
|
||||
args = [[...tarArgs].join(' '), [...compressionArgs].join(' ')]
|
||||
}
|
||||
|
||||
if (BSD_TAR_ZSTD) {
|
||||
return args
|
||||
}
|
||||
|
||||
return [args.join(' ')]
|
||||
}
|
||||
|
||||
function getWorkingDirectory(): string {
|
||||
return process.env['GITHUB_WORKSPACE'] ?? process.cwd()
|
||||
}
|
||||
|
||||
// Common function for extractTar and listTar to get the compression method
|
||||
async function getDecompressionProgram(
|
||||
tarPath: ArchiveTool,
|
||||
compressionMethod: CompressionMethod,
|
||||
archivePath: string
|
||||
): Promise<string[]> {
|
||||
// -d: Decompress.
|
||||
// unzstd is equivalent to 'zstd -d'
|
||||
// --long=#: Enables long distance matching with # bits. Maximum is 30 (1GB) on 32-bit OS and 31 (2GB) on 64-bit.
|
||||
// Using 30 here because we also support 32-bit self-hosted runners.
|
||||
const BSD_TAR_ZSTD =
|
||||
tarPath.type === ArchiveToolType.BSD &&
|
||||
compressionMethod !== CompressionMethod.Gzip &&
|
||||
IS_WINDOWS
|
||||
switch (compressionMethod) {
|
||||
case CompressionMethod.Zstd:
|
||||
return BSD_TAR_ZSTD
|
||||
? [
|
||||
'zstd -d --long=30 --force -o',
|
||||
TarFilename,
|
||||
archivePath.replace(new RegExp(`\\${path.sep}`, 'g'), '/')
|
||||
]
|
||||
: [
|
||||
'--use-compress-program',
|
||||
IS_WINDOWS ? '"zstd -d --long=30"' : 'unzstd --long=30'
|
||||
]
|
||||
case CompressionMethod.ZstdWithoutLong:
|
||||
return BSD_TAR_ZSTD
|
||||
? [
|
||||
'zstd -d --force -o',
|
||||
TarFilename,
|
||||
archivePath.replace(new RegExp(`\\${path.sep}`, 'g'), '/')
|
||||
]
|
||||
: ['--use-compress-program', IS_WINDOWS ? '"zstd -d"' : 'unzstd']
|
||||
default:
|
||||
return ['-z']
|
||||
}
|
||||
}
|
||||
|
||||
// Used for creating the archive
|
||||
// -T#: Compress using # working thread. If # is 0, attempt to detect and use the number of physical CPU cores.
|
||||
// zstdmt is equivalent to 'zstd -T0'
|
||||
// --long=#: Enables long distance matching with # bits. Maximum is 30 (1GB) on 32-bit OS and 31 (2GB) on 64-bit.
|
||||
// Using 30 here because we also support 32-bit self-hosted runners.
|
||||
// Long range mode is added to zstd in v1.3.2 release, so we will not use --long in older version of zstd.
|
||||
async function getCompressionProgram(
|
||||
tarPath: ArchiveTool,
|
||||
compressionMethod: CompressionMethod
|
||||
): Promise<string[]> {
|
||||
const cacheFileName = utils.getCacheFileName(compressionMethod)
|
||||
const BSD_TAR_ZSTD =
|
||||
tarPath.type === ArchiveToolType.BSD &&
|
||||
compressionMethod !== CompressionMethod.Gzip &&
|
||||
IS_WINDOWS
|
||||
switch (compressionMethod) {
|
||||
case CompressionMethod.Zstd:
|
||||
return BSD_TAR_ZSTD
|
||||
? [
|
||||
'zstd -T0 --long=30 --force -o',
|
||||
cacheFileName.replace(new RegExp(`\\${path.sep}`, 'g'), '/'),
|
||||
TarFilename
|
||||
]
|
||||
: [
|
||||
'--use-compress-program',
|
||||
IS_WINDOWS ? '"zstd -T0 --long=30"' : 'zstdmt --long=30'
|
||||
]
|
||||
case CompressionMethod.ZstdWithoutLong:
|
||||
return BSD_TAR_ZSTD
|
||||
? [
|
||||
'zstd -T0 --force -o',
|
||||
cacheFileName.replace(new RegExp(`\\${path.sep}`, 'g'), '/'),
|
||||
TarFilename
|
||||
]
|
||||
: ['--use-compress-program', IS_WINDOWS ? '"zstd -T0"' : 'zstdmt']
|
||||
default:
|
||||
return ['-z']
|
||||
}
|
||||
}
|
||||
|
||||
// Executes all commands as separate processes
|
||||
async function execCommands(commands: string[], cwd?: string): Promise<void> {
|
||||
for (const command of commands) {
|
||||
try {
|
||||
await exec(command, undefined, {
|
||||
cwd,
|
||||
env: {...(process.env as object), MSYS: 'winsymlinks:nativestrict'}
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${command.split(' ')[0]} failed with error: ${error?.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List the contents of a tar
|
||||
export async function listTar(
|
||||
archivePath: string,
|
||||
compressionMethod: CompressionMethod
|
||||
): Promise<void> {
|
||||
const commands = await getCommands(compressionMethod, 'list', archivePath)
|
||||
await execCommands(commands)
|
||||
}
|
||||
|
||||
// Extract a tar
|
||||
export async function extractTar(
|
||||
archivePath: string,
|
||||
compressionMethod: CompressionMethod
|
||||
@@ -59,95 +277,21 @@ export async function extractTar(
|
||||
// Create directory to extract tar into
|
||||
const workingDirectory = getWorkingDirectory()
|
||||
await io.mkdirP(workingDirectory)
|
||||
// --d: Decompress.
|
||||
// --long=#: Enables long distance matching with # bits. Maximum is 30 (1GB) on 32-bit OS and 31 (2GB) on 64-bit.
|
||||
// Using 30 here because we also support 32-bit self-hosted runners.
|
||||
function getCompressionProgram(): string[] {
|
||||
switch (compressionMethod) {
|
||||
case CompressionMethod.Zstd:
|
||||
return ['--use-compress-program', 'zstd -d --long=30']
|
||||
case CompressionMethod.ZstdWithoutLong:
|
||||
return ['--use-compress-program', 'zstd -d']
|
||||
default:
|
||||
return ['-z']
|
||||
}
|
||||
}
|
||||
const args = [
|
||||
...getCompressionProgram(),
|
||||
'-xf',
|
||||
archivePath.replace(new RegExp(`\\${path.sep}`, 'g'), '/'),
|
||||
'-P',
|
||||
'-C',
|
||||
workingDirectory.replace(new RegExp(`\\${path.sep}`, 'g'), '/')
|
||||
]
|
||||
await execTar(args, compressionMethod)
|
||||
const commands = await getCommands(compressionMethod, 'extract', archivePath)
|
||||
await execCommands(commands)
|
||||
}
|
||||
|
||||
// Create a tar
|
||||
export async function createTar(
|
||||
archiveFolder: string,
|
||||
sourceDirectories: string[],
|
||||
compressionMethod: CompressionMethod
|
||||
): Promise<void> {
|
||||
// Write source directories to manifest.txt to avoid command length limits
|
||||
const manifestFilename = 'manifest.txt'
|
||||
const cacheFileName = utils.getCacheFileName(compressionMethod)
|
||||
writeFileSync(
|
||||
path.join(archiveFolder, manifestFilename),
|
||||
path.join(archiveFolder, ManifestFilename),
|
||||
sourceDirectories.join('\n')
|
||||
)
|
||||
const workingDirectory = getWorkingDirectory()
|
||||
|
||||
// -T#: Compress using # working thread. If # is 0, attempt to detect and use the number of physical CPU cores.
|
||||
// --long=#: Enables long distance matching with # bits. Maximum is 30 (1GB) on 32-bit OS and 31 (2GB) on 64-bit.
|
||||
// Using 30 here because we also support 32-bit self-hosted runners.
|
||||
// Long range mode is added to zstd in v1.3.2 release, so we will not use --long in older version of zstd.
|
||||
function getCompressionProgram(): string[] {
|
||||
switch (compressionMethod) {
|
||||
case CompressionMethod.Zstd:
|
||||
return ['--use-compress-program', 'zstd -T0 --long=30']
|
||||
case CompressionMethod.ZstdWithoutLong:
|
||||
return ['--use-compress-program', 'zstd -T0']
|
||||
default:
|
||||
return ['-z']
|
||||
}
|
||||
}
|
||||
const args = [
|
||||
'--posix',
|
||||
...getCompressionProgram(),
|
||||
'-cf',
|
||||
cacheFileName.replace(new RegExp(`\\${path.sep}`, 'g'), '/'),
|
||||
'-P',
|
||||
'-C',
|
||||
workingDirectory.replace(new RegExp(`\\${path.sep}`, 'g'), '/'),
|
||||
'--files-from',
|
||||
manifestFilename
|
||||
]
|
||||
await execTar(args, compressionMethod, archiveFolder)
|
||||
}
|
||||
|
||||
export async function listTar(
|
||||
archivePath: string,
|
||||
compressionMethod: CompressionMethod
|
||||
): Promise<void> {
|
||||
// --d: Decompress.
|
||||
// --long=#: Enables long distance matching with # bits.
|
||||
// Maximum is 30 (1GB) on 32-bit OS and 31 (2GB) on 64-bit.
|
||||
// Using 30 here because we also support 32-bit self-hosted runners.
|
||||
function getCompressionProgram(): string[] {
|
||||
switch (compressionMethod) {
|
||||
case CompressionMethod.Zstd:
|
||||
return ['--use-compress-program', 'zstd -d --long=30']
|
||||
case CompressionMethod.ZstdWithoutLong:
|
||||
return ['--use-compress-program', 'zstd -d']
|
||||
default:
|
||||
return ['-z']
|
||||
}
|
||||
}
|
||||
const args = [
|
||||
...getCompressionProgram(),
|
||||
'-tf',
|
||||
archivePath.replace(new RegExp(`\\${path.sep}`, 'g'), '/'),
|
||||
'-P'
|
||||
]
|
||||
await execTar(args, compressionMethod)
|
||||
const commands = await getCommands(compressionMethod, 'create')
|
||||
await execCommands(commands, archiveFolder)
|
||||
}
|
||||
|
||||
Vendored
+42
-2
@@ -46,6 +46,22 @@ export interface DownloadOptions {
|
||||
* @default 30000
|
||||
*/
|
||||
timeoutInMs?: number
|
||||
|
||||
/**
|
||||
* Time after which a segment download should be aborted if stuck
|
||||
*
|
||||
* @default 3600000
|
||||
*/
|
||||
segmentTimeoutInMs?: number
|
||||
|
||||
/**
|
||||
* Weather to skip downloading the cache entry.
|
||||
* If lookupOnly is set to true, the restore function will only check if
|
||||
* a matching cache entry exists and return the cache key if it does.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
lookupOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,7 +100,9 @@ export function getDownloadOptions(copy?: DownloadOptions): DownloadOptions {
|
||||
const result: DownloadOptions = {
|
||||
useAzureSdk: true,
|
||||
downloadConcurrency: 8,
|
||||
timeoutInMs: 30000
|
||||
timeoutInMs: 30000,
|
||||
segmentTimeoutInMs: 600000,
|
||||
lookupOnly: false
|
||||
}
|
||||
|
||||
if (copy) {
|
||||
@@ -99,11 +117,33 @@ export function getDownloadOptions(copy?: DownloadOptions): DownloadOptions {
|
||||
if (typeof copy.timeoutInMs === 'number') {
|
||||
result.timeoutInMs = copy.timeoutInMs
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof copy.segmentTimeoutInMs === 'number') {
|
||||
result.segmentTimeoutInMs = copy.segmentTimeoutInMs
|
||||
}
|
||||
|
||||
if (typeof copy.lookupOnly === 'boolean') {
|
||||
result.lookupOnly = copy.lookupOnly
|
||||
}
|
||||
}
|
||||
const segmentDownloadTimeoutMins =
|
||||
process.env['SEGMENT_DOWNLOAD_TIMEOUT_MINS']
|
||||
|
||||
if (
|
||||
segmentDownloadTimeoutMins &&
|
||||
!isNaN(Number(segmentDownloadTimeoutMins)) &&
|
||||
isFinite(Number(segmentDownloadTimeoutMins))
|
||||
) {
|
||||
result.segmentTimeoutInMs = Number(segmentDownloadTimeoutMins) * 60 * 1000
|
||||
}
|
||||
core.debug(`Use Azure SDK: ${result.useAzureSdk}`)
|
||||
core.debug(`Download concurrency: ${result.downloadConcurrency}`)
|
||||
core.debug(`Request timeout (ms): ${result.timeoutInMs}`)
|
||||
core.debug(
|
||||
`Cache segment download timeout mins env var: ${process.env['SEGMENT_DOWNLOAD_TIMEOUT_MINS']}`
|
||||
)
|
||||
core.debug(`Segment download timeout (ms): ${result.segmentTimeoutInMs}`)
|
||||
core.debug(`Lookup only: ${result.lookupOnly}`)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
Vendored
+5
-1
@@ -4,7 +4,11 @@
|
||||
"baseUrl": "./",
|
||||
"outDir": "./lib",
|
||||
"rootDir": "./src",
|
||||
"lib": ["es6", "dom"]
|
||||
"lib": [
|
||||
"es6",
|
||||
"dom"
|
||||
],
|
||||
"useUnknownInCatchVariables": false
|
||||
},
|
||||
"include": [
|
||||
"./src"
|
||||
|
||||
+139
-6
@@ -16,11 +16,14 @@ import * as core from '@actions/core';
|
||||
|
||||
#### Inputs/Outputs
|
||||
|
||||
Action inputs can be read with `getInput`. Outputs can be set with `setOutput` which makes them available to be mapped into inputs of other actions to ensure they are decoupled.
|
||||
Action inputs can be read with `getInput` which returns a `string` or `getBooleanInput` which parses a boolean based on the [yaml 1.2 specification](https://yaml.org/spec/1.2/spec.html#id2804923). If `required` set to be false, the input should have a default value in `action.yml`.
|
||||
|
||||
Outputs can be set with `setOutput` which makes them available to be mapped into inputs of other actions to ensure they are decoupled.
|
||||
|
||||
```js
|
||||
const myInput = core.getInput('inputName', { required: true });
|
||||
|
||||
const myBooleanInput = core.getBooleanInput('booleanInputName', { required: true });
|
||||
const myMultilineInput = core.getMultilineInput('multilineInputName', { required: true });
|
||||
core.setOutput('outputKey', 'outputVal');
|
||||
```
|
||||
|
||||
@@ -62,11 +65,10 @@ catch (err) {
|
||||
// setFailed logs the message and sets a failing exit code
|
||||
core.setFailed(`Action failed with error ${err}`);
|
||||
}
|
||||
```
|
||||
|
||||
Note that `setNeutral` is not yet implemented in actions V2 but equivalent functionality is being planned.
|
||||
|
||||
```
|
||||
|
||||
#### Logging
|
||||
|
||||
Finally, this library provides some utilities for logging. Note that debug logging is hidden from the logs by default. This behavior can be toggled by enabling the [Step Debug Logs](../../docs/action-debugging.md#step-debug-logs).
|
||||
@@ -90,6 +92,8 @@ try {
|
||||
|
||||
// Do stuff
|
||||
core.info('Output to the actions build log')
|
||||
|
||||
core.notice('This is a message that will also emit an annotation')
|
||||
}
|
||||
catch (err) {
|
||||
core.error(`Error ${err}, action may still succeed though`);
|
||||
@@ -113,11 +117,65 @@ const result = await core.group('Do something async', async () => {
|
||||
})
|
||||
```
|
||||
|
||||
#### Annotations
|
||||
|
||||
This library has 3 methods that will produce [annotations](https://docs.github.com/en/rest/reference/checks#create-a-check-run).
|
||||
```js
|
||||
core.error('This is a bad error, action may still succeed though.')
|
||||
|
||||
core.warning('Something went wrong, but it\'s not bad enough to fail the build.')
|
||||
|
||||
core.notice('Something happened that you might want to know about.')
|
||||
```
|
||||
|
||||
These will surface to the UI in the Actions page and on Pull Requests. They look something like this:
|
||||
|
||||

|
||||
|
||||
These annotations can also be attached to particular lines and columns of your source files to show exactly where a problem is occuring.
|
||||
|
||||
These options are:
|
||||
```typescript
|
||||
export interface AnnotationProperties {
|
||||
/**
|
||||
* A title for the annotation.
|
||||
*/
|
||||
title?: string
|
||||
|
||||
/**
|
||||
* The name of the file for which the annotation should be created.
|
||||
*/
|
||||
file?: string
|
||||
|
||||
/**
|
||||
* The start line for the annotation.
|
||||
*/
|
||||
startLine?: number
|
||||
|
||||
/**
|
||||
* The end line for the annotation. Defaults to `startLine` when `startLine` is provided.
|
||||
*/
|
||||
endLine?: number
|
||||
|
||||
/**
|
||||
* The start column for the annotation. Cannot be sent when `startLine` and `endLine` are different values.
|
||||
*/
|
||||
startColumn?: number
|
||||
|
||||
/**
|
||||
* The end column for the annotation. Cannot be sent when `startLine` and `endLine` are different values.
|
||||
* Defaults to `startColumn` when `startColumn` is provided.
|
||||
*/
|
||||
endColumn?: number
|
||||
}
|
||||
```
|
||||
|
||||
#### Styling output
|
||||
|
||||
Colored output is supported in the Action logs via standard [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code). 3/4 bit, 8 bit and 24 bit colors are all supported.
|
||||
|
||||
Foreground colors:
|
||||
|
||||
```js
|
||||
// 3/4 bit
|
||||
core.info('\u001b[35mThis foreground will be magenta')
|
||||
@@ -130,6 +188,7 @@ core.info('\u001b[38;2;255;0;0mThis foreground will be bright red')
|
||||
```
|
||||
|
||||
Background colors:
|
||||
|
||||
```js
|
||||
// 3/4 bit
|
||||
core.info('\u001b[43mThis background will be yellow');
|
||||
@@ -156,6 +215,7 @@ core.info('\u001b[31;46mRed foreground with a cyan background and \u001b[1mbold
|
||||
```
|
||||
|
||||
> Note: Escape codes reset at the start of each line
|
||||
|
||||
```js
|
||||
core.info('\u001b[35mThis foreground will be magenta')
|
||||
core.info('This foreground will reset to the default')
|
||||
@@ -170,9 +230,10 @@ core.info(style.color.ansi16m.hex('#abcdef') + 'Hello world!')
|
||||
|
||||
#### Action state
|
||||
|
||||
You can use this library to save state and get state for sharing information between a given wrapper action:
|
||||
You can use this library to save state and get state for sharing information between a given wrapper action:
|
||||
|
||||
**action.yml**:
|
||||
|
||||
**action.yml**
|
||||
```yaml
|
||||
name: 'Wrapper action sample'
|
||||
inputs:
|
||||
@@ -193,6 +254,7 @@ core.saveState("pidToKill", 12345);
|
||||
```
|
||||
|
||||
In action's `cleanup.js`:
|
||||
|
||||
```js
|
||||
const core = require('@actions/core');
|
||||
|
||||
@@ -200,3 +262,74 @@ var pid = core.getState("pidToKill");
|
||||
|
||||
process.kill(pid);
|
||||
```
|
||||
|
||||
#### OIDC Token
|
||||
|
||||
You can use these methods to interact with the GitHub OIDC provider and get a JWT ID token which would help to get access token from third party cloud providers.
|
||||
|
||||
**Method Name**: getIDToken()
|
||||
|
||||
**Inputs**
|
||||
|
||||
audience : optional
|
||||
|
||||
**Outputs**
|
||||
|
||||
A [JWT](https://jwt.io/) ID Token
|
||||
|
||||
In action's `main.ts`:
|
||||
```js
|
||||
const core = require('@actions/core');
|
||||
async function getIDTokenAction(): Promise<void> {
|
||||
|
||||
const audience = core.getInput('audience', {required: false})
|
||||
|
||||
const id_token1 = await core.getIDToken() // ID Token with default audience
|
||||
const id_token2 = await core.getIDToken(audience) // ID token with custom audience
|
||||
|
||||
// this id_token can be used to get access token from third party cloud providers
|
||||
}
|
||||
getIDTokenAction()
|
||||
```
|
||||
|
||||
In action's `actions.yml`:
|
||||
|
||||
```yaml
|
||||
name: 'GetIDToken'
|
||||
description: 'Get ID token from Github OIDC provider'
|
||||
inputs:
|
||||
audience:
|
||||
description: 'Audience for which the ID token is intended for'
|
||||
required: false
|
||||
outputs:
|
||||
id_token1:
|
||||
description: 'ID token obtained from OIDC provider'
|
||||
id_token2:
|
||||
description: 'ID token obtained from OIDC provider'
|
||||
runs:
|
||||
using: 'node12'
|
||||
main: 'dist/index.js'
|
||||
```
|
||||
|
||||
#### Filesystem path helpers
|
||||
|
||||
You can use these methods to manipulate file paths across operating systems.
|
||||
|
||||
The `toPosixPath` function converts input paths to Posix-style (Linux) paths.
|
||||
The `toWin32Path` function converts input paths to Windows-style paths. These
|
||||
functions work independently of the underlying runner operating system.
|
||||
|
||||
```js
|
||||
toPosixPath('\\foo\\bar') // => /foo/bar
|
||||
toWin32Path('/foo/bar') // => \foo\bar
|
||||
```
|
||||
|
||||
The `toPlatformPath` function converts input paths to the expected value on the runner's operating system.
|
||||
|
||||
```js
|
||||
// On a Windows runner.
|
||||
toPlatformPath('/foo/bar') // => \foo\bar
|
||||
|
||||
// On a Linux runner.
|
||||
toPlatformPath('\\foo\\bar') // => /foo/bar
|
||||
```
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# @actions/core Releases
|
||||
|
||||
### 1.10.0
|
||||
- `saveState` and `setOutput` now use environment files if available [#1178](https://github.com/actions/toolkit/pull/1178)
|
||||
- `getMultilineInput` now correctly trims whitespace by default [#1185](https://github.com/actions/toolkit/pull/1185)
|
||||
|
||||
### 1.9.1
|
||||
- Randomize delimiter when calling `core.exportVariable`
|
||||
|
||||
### 1.9.0
|
||||
- Added `toPosixPath`, `toWin32Path` and `toPlatformPath` utilities [#1102](https://github.com/actions/toolkit/pull/1102)
|
||||
|
||||
### 1.8.2
|
||||
- Update to v2.0.1 of `@actions/http-client` [#1087](https://github.com/actions/toolkit/pull/1087)
|
||||
|
||||
### 1.8.1
|
||||
- Update to v2.0.0 of `@actions/http-client`
|
||||
|
||||
### 1.8.0
|
||||
- Deprecate `markdownSummary` extension export in favor of `summary`
|
||||
- https://github.com/actions/toolkit/pull/1072
|
||||
- https://github.com/actions/toolkit/pull/1073
|
||||
|
||||
### 1.7.0
|
||||
- [Added `markdownSummary` extension](https://github.com/actions/toolkit/pull/1014)
|
||||
|
||||
### 1.6.0
|
||||
- [Added OIDC Client function `getIDToken`](https://github.com/actions/toolkit/pull/919)
|
||||
- [Added `file` parameter to `AnnotationProperties`](https://github.com/actions/toolkit/pull/896)
|
||||
|
||||
### 1.5.0
|
||||
- [Added support for notice annotations and more annotation fields](https://github.com/actions/toolkit/pull/855)
|
||||
|
||||
### 1.4.0
|
||||
- [Added the `getMultilineInput` function](https://github.com/actions/toolkit/pull/829)
|
||||
|
||||
### 1.3.0
|
||||
- [Added the trimWhitespace option to getInput](https://github.com/actions/toolkit/pull/802)
|
||||
- [Added the getBooleanInput function](https://github.com/actions/toolkit/pull/725)
|
||||
|
||||
### 1.2.7
|
||||
- [Prepend newline for set-output](https://github.com/actions/toolkit/pull/772)
|
||||
|
||||
### 1.2.6
|
||||
- [Update `exportVariable` and `addPath` to use environment files](https://github.com/actions/toolkit/pull/571)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('@actions/core/src/command', () => {
|
||||
afterEach(() => {})
|
||||
|
||||
afterAll(() => {
|
||||
process.stdout.write = (originalWriteFunction as unknown) as (
|
||||
process.stdout.write = originalWriteFunction as unknown as (
|
||||
str: string
|
||||
) => boolean
|
||||
})
|
||||
@@ -51,8 +51,7 @@ describe('@actions/core/src/command', () => {
|
||||
command.issueCommand(
|
||||
'some-command',
|
||||
{
|
||||
name:
|
||||
'percent % percent % cr \r cr \r lf \n lf \n colon : colon : comma , comma ,'
|
||||
name: 'percent % percent % cr \r cr \r lf \n lf \n colon : colon : comma , comma ,'
|
||||
},
|
||||
''
|
||||
)
|
||||
@@ -117,11 +116,11 @@ describe('@actions/core/src/command', () => {
|
||||
command.issueCommand(
|
||||
'some-command',
|
||||
{
|
||||
prop1: ({test: 'object'} as unknown) as string,
|
||||
prop2: (123 as unknown) as string,
|
||||
prop3: (true as unknown) as string
|
||||
prop1: {test: 'object'} as unknown as string,
|
||||
prop2: 123 as unknown as string,
|
||||
prop3: true as unknown as string
|
||||
},
|
||||
({test: 'object'} as unknown) as string
|
||||
{test: 'object'} as unknown as string
|
||||
)
|
||||
assertWriteCalls([
|
||||
`::some-command prop1={"test"%3A"object"},prop2=123,prop3=true::{"test":"object"}${os.EOL}`
|
||||
|
||||
@@ -2,6 +2,11 @@ import * as fs from 'fs'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import * as core from '../src/core'
|
||||
import {HttpClient} from '@actions/http-client'
|
||||
import {toCommandProperties} from '../src/utils'
|
||||
import * as uuid from 'uuid'
|
||||
|
||||
jest.mock('uuid')
|
||||
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
|
||||
@@ -19,15 +24,31 @@ const testEnvVars = {
|
||||
INPUT_MISSING: '',
|
||||
'INPUT_SPECIAL_CHARS_\'\t"\\': '\'\t"\\ response ',
|
||||
INPUT_MULTIPLE_SPACES_VARIABLE: 'I have multiple spaces',
|
||||
INPUT_BOOLEAN_INPUT: 'true',
|
||||
INPUT_BOOLEAN_INPUT_TRUE1: 'true',
|
||||
INPUT_BOOLEAN_INPUT_TRUE2: 'True',
|
||||
INPUT_BOOLEAN_INPUT_TRUE3: 'TRUE',
|
||||
INPUT_BOOLEAN_INPUT_FALSE1: 'false',
|
||||
INPUT_BOOLEAN_INPUT_FALSE2: 'False',
|
||||
INPUT_BOOLEAN_INPUT_FALSE3: 'FALSE',
|
||||
INPUT_WRONG_BOOLEAN_INPUT: 'wrong',
|
||||
INPUT_WITH_TRAILING_WHITESPACE: ' some val ',
|
||||
INPUT_MY_INPUT_LIST: 'val1\nval2\nval3',
|
||||
INPUT_LIST_WITH_TRAILING_WHITESPACE: ' val1 \n val2 \n ',
|
||||
|
||||
// Save inputs
|
||||
STATE_TEST_1: 'state_val',
|
||||
|
||||
// File Commands
|
||||
GITHUB_PATH: '',
|
||||
GITHUB_ENV: ''
|
||||
GITHUB_ENV: '',
|
||||
GITHUB_OUTPUT: '',
|
||||
GITHUB_STATE: ''
|
||||
}
|
||||
|
||||
const UUID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'
|
||||
const DELIMITER = `ghadelimiter_${UUID}`
|
||||
|
||||
describe('@actions/core', () => {
|
||||
beforeAll(() => {
|
||||
const filePath = path.join(__dirname, `test`)
|
||||
@@ -41,6 +62,14 @@ describe('@actions/core', () => {
|
||||
process.env[key] = testEnvVars[key as keyof typeof testEnvVars]
|
||||
}
|
||||
process.stdout.write = jest.fn()
|
||||
|
||||
jest.spyOn(uuid, 'v4').mockImplementation(() => {
|
||||
return UUID
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('legacy exportVariable produces the correct command and sets the env', () => {
|
||||
@@ -78,7 +107,7 @@ describe('@actions/core', () => {
|
||||
core.exportVariable('my var', 'var val')
|
||||
verifyFileCommand(
|
||||
command,
|
||||
`my var<<_GitHubActionsFileCommandDelimeter_${os.EOL}var val${os.EOL}_GitHubActionsFileCommandDelimeter_${os.EOL}`
|
||||
`my var<<${DELIMITER}${os.EOL}var val${os.EOL}${DELIMITER}${os.EOL}`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -88,7 +117,7 @@ describe('@actions/core', () => {
|
||||
core.exportVariable('my var', true)
|
||||
verifyFileCommand(
|
||||
command,
|
||||
`my var<<_GitHubActionsFileCommandDelimeter_${os.EOL}true${os.EOL}_GitHubActionsFileCommandDelimeter_${os.EOL}`
|
||||
`my var<<${DELIMITER}${os.EOL}true${os.EOL}${DELIMITER}${os.EOL}`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -98,13 +127,45 @@ describe('@actions/core', () => {
|
||||
core.exportVariable('my var', 5)
|
||||
verifyFileCommand(
|
||||
command,
|
||||
`my var<<_GitHubActionsFileCommandDelimeter_${os.EOL}5${os.EOL}_GitHubActionsFileCommandDelimeter_${os.EOL}`
|
||||
`my var<<${DELIMITER}${os.EOL}5${os.EOL}${DELIMITER}${os.EOL}`
|
||||
)
|
||||
})
|
||||
|
||||
it('exportVariable does not allow delimiter as value', () => {
|
||||
const command = 'ENV'
|
||||
createFileCommandFile(command)
|
||||
|
||||
expect(() => {
|
||||
core.exportVariable('my var', `good stuff ${DELIMITER} bad stuff`)
|
||||
}).toThrow(
|
||||
`Unexpected input: value should not contain the delimiter "${DELIMITER}"`
|
||||
)
|
||||
|
||||
const filePath = path.join(__dirname, `test/${command}`)
|
||||
fs.unlinkSync(filePath)
|
||||
})
|
||||
|
||||
it('exportVariable does not allow delimiter as name', () => {
|
||||
const command = 'ENV'
|
||||
createFileCommandFile(command)
|
||||
|
||||
expect(() => {
|
||||
core.exportVariable(`good stuff ${DELIMITER} bad stuff`, 'test')
|
||||
}).toThrow(
|
||||
`Unexpected input: name should not contain the delimiter "${DELIMITER}"`
|
||||
)
|
||||
|
||||
const filePath = path.join(__dirname, `test/${command}`)
|
||||
fs.unlinkSync(filePath)
|
||||
})
|
||||
|
||||
it('setSecret produces the correct command', () => {
|
||||
core.setSecret('secret val')
|
||||
assertWriteCalls([`::add-mask::secret val${os.EOL}`])
|
||||
core.setSecret('multi\nline\r\nsecret')
|
||||
assertWriteCalls([
|
||||
`::add-mask::secret val${os.EOL}`,
|
||||
`::add-mask::multi%0Aline%0D%0Asecret${os.EOL}`
|
||||
])
|
||||
})
|
||||
|
||||
it('prependPath produces the correct commands and sets the env', () => {
|
||||
@@ -157,19 +218,151 @@ describe('@actions/core', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('setOutput produces the correct command', () => {
|
||||
it('getInput trims whitespace by default', () => {
|
||||
expect(core.getInput('with trailing whitespace')).toBe('some val')
|
||||
})
|
||||
|
||||
it('getInput trims whitespace when option is explicitly true', () => {
|
||||
expect(
|
||||
core.getInput('with trailing whitespace', {trimWhitespace: true})
|
||||
).toBe('some val')
|
||||
})
|
||||
|
||||
it('getInput does not trim whitespace when option is false', () => {
|
||||
expect(
|
||||
core.getInput('with trailing whitespace', {trimWhitespace: false})
|
||||
).toBe(' some val ')
|
||||
})
|
||||
|
||||
it('getInput gets non-required boolean input', () => {
|
||||
expect(core.getBooleanInput('boolean input')).toBe(true)
|
||||
})
|
||||
|
||||
it('getInput gets required input', () => {
|
||||
expect(core.getBooleanInput('boolean input', {required: true})).toBe(true)
|
||||
})
|
||||
|
||||
it('getBooleanInput handles boolean input', () => {
|
||||
expect(core.getBooleanInput('boolean input true1')).toBe(true)
|
||||
expect(core.getBooleanInput('boolean input true2')).toBe(true)
|
||||
expect(core.getBooleanInput('boolean input true3')).toBe(true)
|
||||
expect(core.getBooleanInput('boolean input false1')).toBe(false)
|
||||
expect(core.getBooleanInput('boolean input false2')).toBe(false)
|
||||
expect(core.getBooleanInput('boolean input false3')).toBe(false)
|
||||
})
|
||||
|
||||
it('getBooleanInput handles wrong boolean input', () => {
|
||||
expect(() => core.getBooleanInput('wrong boolean input')).toThrow(
|
||||
'Input does not meet YAML 1.2 "Core Schema" specification: wrong boolean input\n' +
|
||||
`Support boolean input list: \`true | True | TRUE | false | False | FALSE\``
|
||||
)
|
||||
})
|
||||
|
||||
it('getMultilineInput works', () => {
|
||||
expect(core.getMultilineInput('my input list')).toEqual([
|
||||
'val1',
|
||||
'val2',
|
||||
'val3'
|
||||
])
|
||||
})
|
||||
|
||||
it('getMultilineInput trims whitespace by default', () => {
|
||||
expect(core.getMultilineInput('list with trailing whitespace')).toEqual([
|
||||
'val1',
|
||||
'val2'
|
||||
])
|
||||
})
|
||||
|
||||
it('getMultilineInput trims whitespace when option is explicitly true', () => {
|
||||
expect(
|
||||
core.getMultilineInput('list with trailing whitespace', {
|
||||
trimWhitespace: true
|
||||
})
|
||||
).toEqual(['val1', 'val2'])
|
||||
})
|
||||
|
||||
it('getMultilineInput does not trim whitespace when option is false', () => {
|
||||
expect(
|
||||
core.getMultilineInput('list with trailing whitespace', {
|
||||
trimWhitespace: false
|
||||
})
|
||||
).toEqual([' val1 ', ' val2 ', ' '])
|
||||
})
|
||||
|
||||
it('legacy setOutput produces the correct command', () => {
|
||||
core.setOutput('some output', 'some value')
|
||||
assertWriteCalls([`::set-output name=some output::some value${os.EOL}`])
|
||||
assertWriteCalls([
|
||||
os.EOL,
|
||||
`::set-output name=some output::some value${os.EOL}`
|
||||
])
|
||||
})
|
||||
|
||||
it('setOutput handles bools', () => {
|
||||
it('legacy setOutput handles bools', () => {
|
||||
core.setOutput('some output', false)
|
||||
assertWriteCalls([`::set-output name=some output::false${os.EOL}`])
|
||||
assertWriteCalls([os.EOL, `::set-output name=some output::false${os.EOL}`])
|
||||
})
|
||||
|
||||
it('setOutput handles numbers', () => {
|
||||
it('legacy setOutput handles numbers', () => {
|
||||
core.setOutput('some output', 1.01)
|
||||
assertWriteCalls([`::set-output name=some output::1.01${os.EOL}`])
|
||||
assertWriteCalls([os.EOL, `::set-output name=some output::1.01${os.EOL}`])
|
||||
})
|
||||
|
||||
it('setOutput produces the correct command and sets the output', () => {
|
||||
const command = 'OUTPUT'
|
||||
createFileCommandFile(command)
|
||||
core.setOutput('my out', 'out val')
|
||||
verifyFileCommand(
|
||||
command,
|
||||
`my out<<${DELIMITER}${os.EOL}out val${os.EOL}${DELIMITER}${os.EOL}`
|
||||
)
|
||||
})
|
||||
|
||||
it('setOutput handles boolean inputs', () => {
|
||||
const command = 'OUTPUT'
|
||||
createFileCommandFile(command)
|
||||
core.setOutput('my out', true)
|
||||
verifyFileCommand(
|
||||
command,
|
||||
`my out<<${DELIMITER}${os.EOL}true${os.EOL}${DELIMITER}${os.EOL}`
|
||||
)
|
||||
})
|
||||
|
||||
it('setOutput handles number inputs', () => {
|
||||
const command = 'OUTPUT'
|
||||
createFileCommandFile(command)
|
||||
core.setOutput('my out', 5)
|
||||
verifyFileCommand(
|
||||
command,
|
||||
`my out<<${DELIMITER}${os.EOL}5${os.EOL}${DELIMITER}${os.EOL}`
|
||||
)
|
||||
})
|
||||
|
||||
it('setOutput does not allow delimiter as value', () => {
|
||||
const command = 'OUTPUT'
|
||||
createFileCommandFile(command)
|
||||
|
||||
expect(() => {
|
||||
core.setOutput('my out', `good stuff ${DELIMITER} bad stuff`)
|
||||
}).toThrow(
|
||||
`Unexpected input: value should not contain the delimiter "${DELIMITER}"`
|
||||
)
|
||||
|
||||
const filePath = path.join(__dirname, `test/${command}`)
|
||||
fs.unlinkSync(filePath)
|
||||
})
|
||||
|
||||
it('setOutput does not allow delimiter as name', () => {
|
||||
const command = 'OUTPUT'
|
||||
createFileCommandFile(command)
|
||||
|
||||
expect(() => {
|
||||
core.setOutput(`good stuff ${DELIMITER} bad stuff`, 'test')
|
||||
}).toThrow(
|
||||
`Unexpected input: name should not contain the delimiter "${DELIMITER}"`
|
||||
)
|
||||
|
||||
const filePath = path.join(__dirname, `test/${command}`)
|
||||
fs.unlinkSync(filePath)
|
||||
})
|
||||
|
||||
it('setFailed sets the correct exit code and failure message', () => {
|
||||
@@ -207,6 +400,21 @@ describe('@actions/core', () => {
|
||||
assertWriteCalls([`::error::Error: ${message}${os.EOL}`])
|
||||
})
|
||||
|
||||
it('error handles parameters correctly', () => {
|
||||
const message = 'this is my error message'
|
||||
core.error(new Error(message), {
|
||||
title: 'A title',
|
||||
file: 'root/test.txt',
|
||||
startColumn: 1,
|
||||
endColumn: 2,
|
||||
startLine: 5,
|
||||
endLine: 5
|
||||
})
|
||||
assertWriteCalls([
|
||||
`::error title=A title,file=root/test.txt,line=5,endLine=5,col=1,endColumn=2::Error: ${message}${os.EOL}`
|
||||
])
|
||||
})
|
||||
|
||||
it('warning sets the correct message', () => {
|
||||
core.warning('Warning')
|
||||
assertWriteCalls([`::warning::Warning${os.EOL}`])
|
||||
@@ -223,6 +431,72 @@ describe('@actions/core', () => {
|
||||
assertWriteCalls([`::warning::Error: ${message}${os.EOL}`])
|
||||
})
|
||||
|
||||
it('warning handles parameters correctly', () => {
|
||||
const message = 'this is my error message'
|
||||
core.warning(new Error(message), {
|
||||
title: 'A title',
|
||||
file: 'root/test.txt',
|
||||
startColumn: 1,
|
||||
endColumn: 2,
|
||||
startLine: 5,
|
||||
endLine: 5
|
||||
})
|
||||
assertWriteCalls([
|
||||
`::warning title=A title,file=root/test.txt,line=5,endLine=5,col=1,endColumn=2::Error: ${message}${os.EOL}`
|
||||
])
|
||||
})
|
||||
|
||||
it('notice sets the correct message', () => {
|
||||
core.notice('Notice')
|
||||
assertWriteCalls([`::notice::Notice${os.EOL}`])
|
||||
})
|
||||
|
||||
it('notice escapes the message', () => {
|
||||
core.notice('\r\nnotice\n')
|
||||
assertWriteCalls([`::notice::%0D%0Anotice%0A${os.EOL}`])
|
||||
})
|
||||
|
||||
it('notice handles an error object', () => {
|
||||
const message = 'this is my error message'
|
||||
core.notice(new Error(message))
|
||||
assertWriteCalls([`::notice::Error: ${message}${os.EOL}`])
|
||||
})
|
||||
|
||||
it('notice handles parameters correctly', () => {
|
||||
const message = 'this is my error message'
|
||||
core.notice(new Error(message), {
|
||||
title: 'A title',
|
||||
file: 'root/test.txt',
|
||||
startColumn: 1,
|
||||
endColumn: 2,
|
||||
startLine: 5,
|
||||
endLine: 5
|
||||
})
|
||||
assertWriteCalls([
|
||||
`::notice title=A title,file=root/test.txt,line=5,endLine=5,col=1,endColumn=2::Error: ${message}${os.EOL}`
|
||||
])
|
||||
})
|
||||
|
||||
it('annotations map field names correctly', () => {
|
||||
const commandProperties = toCommandProperties({
|
||||
title: 'A title',
|
||||
file: 'root/test.txt',
|
||||
startColumn: 1,
|
||||
endColumn: 2,
|
||||
startLine: 5,
|
||||
endLine: 5
|
||||
})
|
||||
expect(commandProperties.title).toBe('A title')
|
||||
expect(commandProperties.file).toBe('root/test.txt')
|
||||
expect(commandProperties.col).toBe(1)
|
||||
expect(commandProperties.endColumn).toBe(2)
|
||||
expect(commandProperties.line).toBe(5)
|
||||
expect(commandProperties.endLine).toBe(5)
|
||||
|
||||
expect(commandProperties.startColumn).toBeUndefined()
|
||||
expect(commandProperties.startLine).toBeUndefined()
|
||||
})
|
||||
|
||||
it('startGroup starts a new group', () => {
|
||||
core.startGroup('my-group')
|
||||
assertWriteCalls([`::group::my-group${os.EOL}`])
|
||||
@@ -256,21 +530,79 @@ describe('@actions/core', () => {
|
||||
assertWriteCalls([`::debug::%0D%0Adebug%0A${os.EOL}`])
|
||||
})
|
||||
|
||||
it('saveState produces the correct command', () => {
|
||||
it('legacy saveState produces the correct command', () => {
|
||||
core.saveState('state_1', 'some value')
|
||||
assertWriteCalls([`::save-state name=state_1::some value${os.EOL}`])
|
||||
})
|
||||
|
||||
it('saveState handles numbers', () => {
|
||||
it('legacy saveState handles numbers', () => {
|
||||
core.saveState('state_1', 1)
|
||||
assertWriteCalls([`::save-state name=state_1::1${os.EOL}`])
|
||||
})
|
||||
|
||||
it('saveState handles bools', () => {
|
||||
it('legacy saveState handles bools', () => {
|
||||
core.saveState('state_1', true)
|
||||
assertWriteCalls([`::save-state name=state_1::true${os.EOL}`])
|
||||
})
|
||||
|
||||
it('saveState produces the correct command and saves the state', () => {
|
||||
const command = 'STATE'
|
||||
createFileCommandFile(command)
|
||||
core.saveState('my state', 'out val')
|
||||
verifyFileCommand(
|
||||
command,
|
||||
`my state<<${DELIMITER}${os.EOL}out val${os.EOL}${DELIMITER}${os.EOL}`
|
||||
)
|
||||
})
|
||||
|
||||
it('saveState handles boolean inputs', () => {
|
||||
const command = 'STATE'
|
||||
createFileCommandFile(command)
|
||||
core.saveState('my state', true)
|
||||
verifyFileCommand(
|
||||
command,
|
||||
`my state<<${DELIMITER}${os.EOL}true${os.EOL}${DELIMITER}${os.EOL}`
|
||||
)
|
||||
})
|
||||
|
||||
it('saveState handles number inputs', () => {
|
||||
const command = 'STATE'
|
||||
createFileCommandFile(command)
|
||||
core.saveState('my state', 5)
|
||||
verifyFileCommand(
|
||||
command,
|
||||
`my state<<${DELIMITER}${os.EOL}5${os.EOL}${DELIMITER}${os.EOL}`
|
||||
)
|
||||
})
|
||||
|
||||
it('saveState does not allow delimiter as value', () => {
|
||||
const command = 'STATE'
|
||||
createFileCommandFile(command)
|
||||
|
||||
expect(() => {
|
||||
core.saveState('my state', `good stuff ${DELIMITER} bad stuff`)
|
||||
}).toThrow(
|
||||
`Unexpected input: value should not contain the delimiter "${DELIMITER}"`
|
||||
)
|
||||
|
||||
const filePath = path.join(__dirname, `test/${command}`)
|
||||
fs.unlinkSync(filePath)
|
||||
})
|
||||
|
||||
it('saveState does not allow delimiter as name', () => {
|
||||
const command = 'STATE'
|
||||
createFileCommandFile(command)
|
||||
|
||||
expect(() => {
|
||||
core.saveState(`good stuff ${DELIMITER} bad stuff`, 'test')
|
||||
}).toThrow(
|
||||
`Unexpected input: name should not contain the delimiter "${DELIMITER}"`
|
||||
)
|
||||
|
||||
const filePath = path.join(__dirname, `test/${command}`)
|
||||
fs.unlinkSync(filePath)
|
||||
})
|
||||
|
||||
it('getState gets wrapper action state', () => {
|
||||
expect(core.getState('TEST_1')).toBe('state_val')
|
||||
})
|
||||
@@ -325,3 +657,20 @@ function verifyFileCommand(command: string, expectedContents: string): void {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
function getTokenEndPoint(): string {
|
||||
return 'https://vstoken.actions.githubusercontent.com/.well-known/openid-configuration'
|
||||
}
|
||||
|
||||
describe('oidc-client-tests', () => {
|
||||
it('Get Http Client', async () => {
|
||||
const http = new HttpClient('actions/oidc-client')
|
||||
expect(http).toBeDefined()
|
||||
})
|
||||
|
||||
it('HTTP get request to get token endpoint', async () => {
|
||||
const http = new HttpClient('actions/oidc-client')
|
||||
const res = await http.get(getTokenEndPoint())
|
||||
expect(res.message.statusCode).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import * as path from 'path'
|
||||
|
||||
import {toPlatformPath, toPosixPath, toWin32Path} from '../src/path-utils'
|
||||
|
||||
describe('#toPosixPath', () => {
|
||||
const cases: {
|
||||
only?: boolean
|
||||
name: string
|
||||
input: string
|
||||
expected: string
|
||||
}[] = [
|
||||
{
|
||||
name: 'empty string',
|
||||
input: '',
|
||||
expected: ''
|
||||
},
|
||||
{
|
||||
name: 'single value',
|
||||
input: 'foo',
|
||||
expected: 'foo'
|
||||
},
|
||||
{
|
||||
name: 'with posix relative',
|
||||
input: 'foo/bar/baz',
|
||||
expected: 'foo/bar/baz'
|
||||
},
|
||||
{
|
||||
name: 'with posix absolute',
|
||||
input: '/foo/bar/baz',
|
||||
expected: '/foo/bar/baz'
|
||||
},
|
||||
{
|
||||
name: 'with win32 relative',
|
||||
input: 'foo\\bar\\baz',
|
||||
expected: 'foo/bar/baz'
|
||||
},
|
||||
{
|
||||
name: 'with win32 absolute',
|
||||
input: '\\foo\\bar\\baz',
|
||||
expected: '/foo/bar/baz'
|
||||
},
|
||||
{
|
||||
name: 'with a mix',
|
||||
input: '\\foo/bar/baz',
|
||||
expected: '/foo/bar/baz'
|
||||
}
|
||||
]
|
||||
|
||||
for (const tc of cases) {
|
||||
const fn = tc.only ? it.only : it
|
||||
fn(tc.name, () => {
|
||||
const result = toPosixPath(tc.input)
|
||||
expect(result).toEqual(tc.expected)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('#toWin32Path', () => {
|
||||
const cases: {
|
||||
only?: boolean
|
||||
name: string
|
||||
input: string
|
||||
expected: string
|
||||
}[] = [
|
||||
{
|
||||
name: 'empty string',
|
||||
input: '',
|
||||
expected: ''
|
||||
},
|
||||
{
|
||||
name: 'single value',
|
||||
input: 'foo',
|
||||
expected: 'foo'
|
||||
},
|
||||
{
|
||||
name: 'with posix relative',
|
||||
input: 'foo/bar/baz',
|
||||
expected: 'foo\\bar\\baz'
|
||||
},
|
||||
{
|
||||
name: 'with posix absolute',
|
||||
input: '/foo/bar/baz',
|
||||
expected: '\\foo\\bar\\baz'
|
||||
},
|
||||
{
|
||||
name: 'with win32 relative',
|
||||
input: 'foo\\bar\\baz',
|
||||
expected: 'foo\\bar\\baz'
|
||||
},
|
||||
{
|
||||
name: 'with win32 absolute',
|
||||
input: '\\foo\\bar\\baz',
|
||||
expected: '\\foo\\bar\\baz'
|
||||
},
|
||||
{
|
||||
name: 'with a mix',
|
||||
input: '\\foo/bar\\baz',
|
||||
expected: '\\foo\\bar\\baz'
|
||||
}
|
||||
]
|
||||
|
||||
for (const tc of cases) {
|
||||
const fn = tc.only ? it.only : it
|
||||
fn(tc.name, () => {
|
||||
const result = toWin32Path(tc.input)
|
||||
expect(result).toEqual(tc.expected)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('#toPlatformPath', () => {
|
||||
const cases: {
|
||||
only?: boolean
|
||||
name: string
|
||||
input: string
|
||||
expected: string
|
||||
}[] = [
|
||||
{
|
||||
name: 'empty string',
|
||||
input: '',
|
||||
expected: ''
|
||||
},
|
||||
{
|
||||
name: 'single value',
|
||||
input: 'foo',
|
||||
expected: 'foo'
|
||||
},
|
||||
{
|
||||
name: 'with posix relative',
|
||||
input: 'foo/bar/baz',
|
||||
expected: path.join('foo', 'bar', 'baz')
|
||||
},
|
||||
{
|
||||
name: 'with posix absolute',
|
||||
input: '/foo/bar/baz',
|
||||
expected: path.join(path.sep, 'foo', 'bar', 'baz')
|
||||
},
|
||||
{
|
||||
name: 'with win32 relative',
|
||||
input: 'foo\\bar\\baz',
|
||||
expected: path.join('foo', 'bar', 'baz')
|
||||
},
|
||||
{
|
||||
name: 'with win32 absolute',
|
||||
input: '\\foo\\bar\\baz',
|
||||
expected: path.join(path.sep, 'foo', 'bar', 'baz')
|
||||
},
|
||||
{
|
||||
name: 'with a mix',
|
||||
input: '\\foo/bar\\baz',
|
||||
expected: path.join(path.sep, 'foo', 'bar', 'baz')
|
||||
}
|
||||
]
|
||||
|
||||
for (const tc of cases) {
|
||||
const fn = tc.only ? it.only : it
|
||||
fn(tc.name, () => {
|
||||
const result = toPlatformPath(tc.input)
|
||||
expect(result).toEqual(tc.expected)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,272 @@
|
||||
import * as fs from 'fs'
|
||||
import * as os from 'os'
|
||||
import path from 'path'
|
||||
import {summary, SUMMARY_ENV_VAR} from '../src/summary'
|
||||
|
||||
const testDirectoryPath = path.join(__dirname, 'test')
|
||||
const testFilePath = path.join(testDirectoryPath, 'test-summary.md')
|
||||
|
||||
async function assertSummary(expected: string): Promise<void> {
|
||||
const file = await fs.promises.readFile(testFilePath, {encoding: 'utf8'})
|
||||
expect(file).toEqual(expected)
|
||||
}
|
||||
|
||||
const fixtures = {
|
||||
text: 'hello world 🌎',
|
||||
code: `func fork() {
|
||||
for {
|
||||
go fork()
|
||||
}
|
||||
}`,
|
||||
list: ['foo', 'bar', 'baz', '💣'],
|
||||
table: [
|
||||
[
|
||||
{
|
||||
data: 'foo',
|
||||
header: true
|
||||
},
|
||||
{
|
||||
data: 'bar',
|
||||
header: true
|
||||
},
|
||||
{
|
||||
data: 'baz',
|
||||
header: true
|
||||
},
|
||||
{
|
||||
data: 'tall',
|
||||
rowspan: '3'
|
||||
}
|
||||
],
|
||||
['one', 'two', 'three'],
|
||||
[
|
||||
{
|
||||
data: 'wide',
|
||||
colspan: '3'
|
||||
}
|
||||
]
|
||||
],
|
||||
details: {
|
||||
label: 'open me',
|
||||
content: '🎉 surprise'
|
||||
},
|
||||
img: {
|
||||
src: 'https://github.com/actions.png',
|
||||
alt: 'actions logo',
|
||||
options: {
|
||||
width: '32',
|
||||
height: '32'
|
||||
}
|
||||
},
|
||||
quote: {
|
||||
text: 'Where the world builds software',
|
||||
cite: 'https://github.com/about'
|
||||
},
|
||||
link: {
|
||||
text: 'GitHub',
|
||||
href: 'https://github.com/'
|
||||
}
|
||||
}
|
||||
|
||||
describe('@actions/core/src/summary', () => {
|
||||
beforeEach(async () => {
|
||||
process.env[SUMMARY_ENV_VAR] = testFilePath
|
||||
await fs.promises.mkdir(testDirectoryPath, {recursive: true})
|
||||
await fs.promises.writeFile(testFilePath, '', {encoding: 'utf8'})
|
||||
summary.emptyBuffer()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.promises.unlink(testFilePath)
|
||||
})
|
||||
|
||||
it('throws if summary env var is undefined', async () => {
|
||||
process.env[SUMMARY_ENV_VAR] = undefined
|
||||
const write = summary.addRaw(fixtures.text).write()
|
||||
|
||||
await expect(write).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('throws if summary file does not exist', async () => {
|
||||
await fs.promises.unlink(testFilePath)
|
||||
const write = summary.addRaw(fixtures.text).write()
|
||||
|
||||
await expect(write).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('appends text to summary file', async () => {
|
||||
await fs.promises.writeFile(testFilePath, '# ', {encoding: 'utf8'})
|
||||
await summary.addRaw(fixtures.text).write()
|
||||
await assertSummary(`# ${fixtures.text}`)
|
||||
})
|
||||
|
||||
it('overwrites text to summary file', async () => {
|
||||
await fs.promises.writeFile(testFilePath, 'overwrite', {encoding: 'utf8'})
|
||||
await summary.addRaw(fixtures.text).write({overwrite: true})
|
||||
await assertSummary(fixtures.text)
|
||||
})
|
||||
|
||||
it('appends text with EOL to summary file', async () => {
|
||||
await fs.promises.writeFile(testFilePath, '# ', {encoding: 'utf8'})
|
||||
await summary.addRaw(fixtures.text, true).write()
|
||||
await assertSummary(`# ${fixtures.text}${os.EOL}`)
|
||||
})
|
||||
|
||||
it('chains appends text to summary file', async () => {
|
||||
await fs.promises.writeFile(testFilePath, '', {encoding: 'utf8'})
|
||||
await summary
|
||||
.addRaw(fixtures.text)
|
||||
.addRaw(fixtures.text)
|
||||
.addRaw(fixtures.text)
|
||||
.write()
|
||||
await assertSummary([fixtures.text, fixtures.text, fixtures.text].join(''))
|
||||
})
|
||||
|
||||
it('empties buffer after write', async () => {
|
||||
await fs.promises.writeFile(testFilePath, '', {encoding: 'utf8'})
|
||||
await summary.addRaw(fixtures.text).write()
|
||||
await assertSummary(fixtures.text)
|
||||
expect(summary.isEmptyBuffer()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns summary buffer as string', () => {
|
||||
summary.addRaw(fixtures.text)
|
||||
expect(summary.stringify()).toEqual(fixtures.text)
|
||||
})
|
||||
|
||||
it('return correct values for isEmptyBuffer', () => {
|
||||
summary.addRaw(fixtures.text)
|
||||
expect(summary.isEmptyBuffer()).toBe(false)
|
||||
|
||||
summary.emptyBuffer()
|
||||
expect(summary.isEmptyBuffer()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears a buffer and summary file', async () => {
|
||||
await fs.promises.writeFile(testFilePath, 'content', {encoding: 'utf8'})
|
||||
await summary.clear()
|
||||
await assertSummary('')
|
||||
expect(summary.isEmptyBuffer()).toBe(true)
|
||||
})
|
||||
|
||||
it('adds EOL', async () => {
|
||||
await summary.addRaw(fixtures.text).addEOL().write()
|
||||
await assertSummary(fixtures.text + os.EOL)
|
||||
})
|
||||
|
||||
it('adds a code block without language', async () => {
|
||||
await summary.addCodeBlock(fixtures.code).write()
|
||||
const expected = `<pre><code>func fork() {\n for {\n go fork()\n }\n}</code></pre>${os.EOL}`
|
||||
await assertSummary(expected)
|
||||
})
|
||||
|
||||
it('adds a code block with a language', async () => {
|
||||
await summary.addCodeBlock(fixtures.code, 'go').write()
|
||||
const expected = `<pre lang="go"><code>func fork() {\n for {\n go fork()\n }\n}</code></pre>${os.EOL}`
|
||||
await assertSummary(expected)
|
||||
})
|
||||
|
||||
it('adds an unordered list', async () => {
|
||||
await summary.addList(fixtures.list).write()
|
||||
const expected = `<ul><li>foo</li><li>bar</li><li>baz</li><li>💣</li></ul>${os.EOL}`
|
||||
await assertSummary(expected)
|
||||
})
|
||||
|
||||
it('adds an ordered list', async () => {
|
||||
await summary.addList(fixtures.list, true).write()
|
||||
const expected = `<ol><li>foo</li><li>bar</li><li>baz</li><li>💣</li></ol>${os.EOL}`
|
||||
await assertSummary(expected)
|
||||
})
|
||||
|
||||
it('adds a table', async () => {
|
||||
await summary.addTable(fixtures.table).write()
|
||||
const expected = `<table><tr><th>foo</th><th>bar</th><th>baz</th><td rowspan="3">tall</td></tr><tr><td>one</td><td>two</td><td>three</td></tr><tr><td colspan="3">wide</td></tr></table>${os.EOL}`
|
||||
await assertSummary(expected)
|
||||
})
|
||||
|
||||
it('adds a details element', async () => {
|
||||
await summary
|
||||
.addDetails(fixtures.details.label, fixtures.details.content)
|
||||
.write()
|
||||
const expected = `<details><summary>open me</summary>🎉 surprise</details>${os.EOL}`
|
||||
await assertSummary(expected)
|
||||
})
|
||||
|
||||
it('adds an image with alt text', async () => {
|
||||
await summary.addImage(fixtures.img.src, fixtures.img.alt).write()
|
||||
const expected = `<img src="https://github.com/actions.png" alt="actions logo">${os.EOL}`
|
||||
await assertSummary(expected)
|
||||
})
|
||||
|
||||
it('adds an image with custom dimensions', async () => {
|
||||
await summary
|
||||
.addImage(fixtures.img.src, fixtures.img.alt, fixtures.img.options)
|
||||
.write()
|
||||
const expected = `<img src="https://github.com/actions.png" alt="actions logo" width="32" height="32">${os.EOL}`
|
||||
await assertSummary(expected)
|
||||
})
|
||||
|
||||
it('adds an image with custom dimensions', async () => {
|
||||
await summary
|
||||
.addImage(fixtures.img.src, fixtures.img.alt, fixtures.img.options)
|
||||
.write()
|
||||
const expected = `<img src="https://github.com/actions.png" alt="actions logo" width="32" height="32">${os.EOL}`
|
||||
await assertSummary(expected)
|
||||
})
|
||||
|
||||
it('adds headings h1...h6', async () => {
|
||||
for (const i of [1, 2, 3, 4, 5, 6]) {
|
||||
summary.addHeading('heading', i)
|
||||
}
|
||||
await summary.write()
|
||||
const expected = `<h1>heading</h1>${os.EOL}<h2>heading</h2>${os.EOL}<h3>heading</h3>${os.EOL}<h4>heading</h4>${os.EOL}<h5>heading</h5>${os.EOL}<h6>heading</h6>${os.EOL}`
|
||||
await assertSummary(expected)
|
||||
})
|
||||
|
||||
it('adds h1 if heading level not specified', async () => {
|
||||
await summary.addHeading('heading').write()
|
||||
const expected = `<h1>heading</h1>${os.EOL}`
|
||||
await assertSummary(expected)
|
||||
})
|
||||
|
||||
it('uses h1 if heading level is garbage or out of range', async () => {
|
||||
await summary
|
||||
.addHeading('heading', 'foobar')
|
||||
.addHeading('heading', 1337)
|
||||
.addHeading('heading', -1)
|
||||
.addHeading('heading', Infinity)
|
||||
.write()
|
||||
const expected = `<h1>heading</h1>${os.EOL}<h1>heading</h1>${os.EOL}<h1>heading</h1>${os.EOL}<h1>heading</h1>${os.EOL}`
|
||||
await assertSummary(expected)
|
||||
})
|
||||
|
||||
it('adds a separator', async () => {
|
||||
await summary.addSeparator().write()
|
||||
const expected = `<hr>${os.EOL}`
|
||||
await assertSummary(expected)
|
||||
})
|
||||
|
||||
it('adds a break', async () => {
|
||||
await summary.addBreak().write()
|
||||
const expected = `<br>${os.EOL}`
|
||||
await assertSummary(expected)
|
||||
})
|
||||
|
||||
it('adds a quote', async () => {
|
||||
await summary.addQuote(fixtures.quote.text).write()
|
||||
const expected = `<blockquote>Where the world builds software</blockquote>${os.EOL}`
|
||||
await assertSummary(expected)
|
||||
})
|
||||
|
||||
it('adds a quote with citation', async () => {
|
||||
await summary.addQuote(fixtures.quote.text, fixtures.quote.cite).write()
|
||||
const expected = `<blockquote cite="https://github.com/about">Where the world builds software</blockquote>${os.EOL}`
|
||||
await assertSummary(expected)
|
||||
})
|
||||
|
||||
it('adds a link with href', async () => {
|
||||
await summary.addLink(fixtures.link.text, fixtures.link.href).write()
|
||||
const expected = `<a href="https://github.com/">GitHub</a>${os.EOL}`
|
||||
await assertSummary(expected)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user