Compare commits
761 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 4dd900dde0 | |||
| 383ec9fb03 | |||
| c3478210af | |||
| 228a9534d1 | |||
| 2202465c69 | |||
| ccd1dd298f | |||
| 825204968b | |||
| 85f6235ca9 | |||
| bfdba95ece | |||
| c861dd8859 | |||
| 73d5917a6b | |||
| 4e1ffd548b | |||
| 42b3ff04b2 | |||
| 8d11ee5a8c | |||
| 7ad5004f87 | |||
| b593d1deb4 | |||
| 5be846b72d | |||
| dc491a61ca | |||
| a600dd34a5 | |||
| d4e990d92f | |||
| 05b1692026 | |||
| 8ed9455d68 | |||
| b55731c11b | |||
| 6b83f0554a | |||
| 990647a104 | |||
| 520206f818 | |||
| ff4308098f | |||
| 2bf7365352 | |||
| e7eb2c7418 | |||
| 0b69311011 | |||
| 5e5e1b7aac | |||
| 5e8657cf12 | |||
| 678f278caa | |||
| f1b118b2a9 | |||
| 464aebd43b | |||
| c3c81d44c1 | |||
| af82147423 | |||
| 4f7fb6513a | |||
| 2178f0baee | |||
| a5e05630d8 | |||
| 0759cdc230 | |||
| da34bfb74d | |||
| bb290885a3 | |||
| ace7a82469 | |||
| 71b19c1d65 | |||
| c9819f79d2 | |||
| c2bc747506 | |||
| ea69f13737 | |||
| 7f7e22a940 | |||
| de52c861c1 | |||
| 1ef26b2390 | |||
| 9ad01e4fd3 | |||
| 6c5508d1fb | |||
| b2cba168a2 | |||
| e3c6237940 | |||
| fc28be88e4 | |||
| 905b2c7b06 | |||
| 3a9dc00629 | |||
| ccad19055e | |||
| cb18a3df6b | |||
| 781092b1d1 | |||
| 1cc56db0ff | |||
| 0bf9897205 | |||
| b152907c1f | |||
| 8f6ddeb087 | |||
| 32f15666bd | |||
| 2710592b73 | |||
| 7e1c59c51e | |||
| 95a10d23fa | |||
| 8fdeff41f3 | |||
| c4a92b0b60 | |||
| cc474239c9 | |||
| 4964b0cc7c | |||
| cee7d92d1d | |||
| ea503a235e | |||
| 2d47f7b7f6 | |||
| 7a1a0dd6fc | |||
| 9e2d61e548 | |||
| 66931ff481 | |||
| 8e14ff9f0a | |||
| 3e40dd39cc | |||
| c65fe87e33 | |||
| d4340966b7 | |||
| d69e699ab9 | |||
| 4a89cf72de | |||
| 9ba7c679ad | |||
| c94bc40d84 | |||
| 8ae8acce72 | |||
| 30e0a77337 | |||
| 44a99f6c43 | |||
| f84d1a2ae2 | |||
| 2c693984a8 | |||
| f4aa824135 | |||
| 4e9375da09 | |||
| e3a666f5b7 | |||
| dcf5c88bb3 | |||
| 0dea61a3a8 | |||
| fde6221228 | |||
| 9a3466a094 | |||
| 5112dc231e | |||
| 77761a4dc9 | |||
| a67b91ea15 | |||
| 2fdf3b71f8 | |||
| d2b2399bd2 | |||
| 628f82f221 | |||
| 6d83c79964 | |||
| 36e732155e | |||
| b3c8e19a7a | |||
| 1413cd0e32 | |||
| c534ad2cbd | |||
| 15fefd9336 | |||
| 7409ad5fae | |||
| 932779cf58 | |||
| 0471ed4ad7 | |||
| d1b52e7168 | |||
| 83dd3ef0f1 | |||
| a5ff692285 | |||
| 264e52add6 | |||
| 11dcc8b313 | |||
| 6a744be7ed | |||
| 94cd8efa47 | |||
| 97cabf0eb9 | |||
| 187d4aa625 | |||
| 57d20b4db4 | |||
| 4e734dc4c1 | |||
| 34f71e80ce | |||
| 1e88dec883 | |||
| 7257597d73 | |||
| df7e2c13c8 | |||
| eec6689a61 | |||
| 1688b117e1 | |||
| bb1053a8a7 | |||
| a28977e977 | |||
| c4b6011310 | |||
| 05e39f551d | |||
| 3c125ce4e0 | |||
| 5b940ebda7 | |||
| a6a5d98be8 | |||
| c010a271d9 | |||
| 1b521c4778 | |||
| 36a4b7df61 | |||
| 905c2aa216 | |||
| cb7022ea2c | |||
| b94f6a1340 | |||
| 12f30111a0 | |||
| 17b97eceec | |||
| b0e01b71c0 | |||
| 3748609c73 | |||
| 37590cb3ee | |||
| dffb5572a9 | |||
| 5859d7172e | |||
| 82fbe5da0f | |||
| df0aa9077a | |||
| 259743ae13 | |||
| 6459481e98 | |||
| 3261dd9883 | |||
| a649207792 | |||
| 54bcb7c4f1 | |||
| d17d4a9163 | |||
| 41157b23c7 | |||
| fa03eb4d22 | |||
| f383109dc3 | |||
| 84f1e31b69 | |||
| f90a2dcfd4 | |||
| 9cfb1604bd | |||
| 2dcdf049a8 | |||
| 6cbb8e9bc8 | |||
| 0ecc141d4e | |||
| 432a78c48c | |||
| 80e6ba7033 | |||
| 6072c249ee | |||
| a9175f3986 | |||
| 8b0300129f | |||
| ab5bd9d696 | |||
| e69833ed16 | |||
| 461fc2b9c9 | |||
| c514e7481a | |||
| 2e88402d19 | |||
| 058ad6937d | |||
| dd64d8c7c9 | |||
| 947ba5b559 | |||
| 03ebc5b885 | |||
| 1a2c592903 | |||
| 683245ad5e | |||
| bfd29dcef8 | |||
| 803934eca0 | |||
| 4e69ce10e9 | |||
| a11539e1db | |||
| a94e2440cb | |||
| 8a4134761f | |||
| 60d3096c71 | |||
| 5feb835dff | |||
| e7cbd693eb | |||
| 81bdf00982 | |||
| 568f12cee6 | |||
| f79897266e | |||
| 606e1f27ac | |||
| 895bdd6dd5 | |||
| 61d502068b | |||
| 17acd9c66f | |||
| d4975510fe | |||
| 871c495487 | |||
| 40a502b14b | |||
| fc45b70f30 | |||
| 9f6c37ac52 | |||
| 80e91ee891 | |||
| bedf824517 | |||
| be9f18b69f | |||
| 1d7e38e56d | |||
| aa13e110b1 | |||
| 15033a1aed | |||
| cd47f1e123 | |||
| 0f1fef3752 | |||
| 5fdab2aaf2 | |||
| 1c12ced7ba | |||
| 211b25966b | |||
| d98e55434d | |||
| 5c894298f2 | |||
| 9a3c005162 | |||
| 225370fc48 | |||
| 28a7970270 | |||
| 6c824bd448 | |||
| 47357ddfee | |||
| a465bf5e6d | |||
| 0fbdc19f81 | |||
| 4f11810a00 | |||
| 46c2a7e41a | |||
| 626bbe7136 | |||
| 1e5fc20bfe | |||
| a9ebfb1a78 | |||
| 4a3fe0bcd3 | |||
| 3d556ddb81 | |||
| a65441cf46 | |||
| 565d0bbe18 | |||
| e8d384d3af | |||
| 55b188b8c6 | |||
| 747fa4805a | |||
| 9c0a43bda4 | |||
| e984b2b6bb | |||
| c2bb007435 | |||
| 2e4712de6f | |||
| ae706665a1 | |||
| 7b46e3ab34 | |||
| b2151226b6 | |||
| 1643ea2734 | |||
| 5ce4932391 | |||
| a1c30dfc53 | |||
| f210cdb256 | |||
| 531da1858f | |||
| 9d54cd22ea | |||
| 713902387e | |||
| 05b1b08f77 | |||
| 47ccfea021 | |||
| 46bd5e54fd | |||
| 14ac06ecd8 | |||
| 9b019476db | |||
| 1f7964519a | |||
| 67eeeea9fa | |||
| 3116829a9b | |||
| 547d771ca3 | |||
| 4897b2cd3b | |||
| 81b71dc6e6 | |||
| 4d15218252 | |||
| b62614fa25 | |||
| e2358e2973 | |||
| 14d6a0a2d2 | |||
| 6fcaac5046 | |||
| b297969f56 | |||
| da04d22321 | |||
| 5fd70ca47a | |||
| 2c6d31be8f | |||
| b3bf422391 | |||
| 4ff3b554b8 | |||
| 3dcd65e44b | |||
| ef4525e9dd | |||
| a6e7249776 | |||
| b529540e0c | |||
| f689e65c54 | |||
| 7bc0d5222f | |||
| 7f17a6e550 | |||
| 6160df50dc | |||
| a2ab4bcf78 | |||
| 7772d5f810 | |||
| 1c4866fa48 | |||
| ebace7edd3 | |||
| e533651251 | |||
| eb4c32847c | |||
| 020f7034f4 | |||
| 2a1b7d5c7e | |||
| eaba9217f8 | |||
| f2d01998f0 | |||
| 99d3ad0a64 | |||
| ac36ca4405 | |||
| 92e6443cf0 | |||
| 8f9992ca17 | |||
| 80fc75ef9c | |||
| 8b9dfa809b | |||
| e35e0e640b | |||
| ccf748b53b | |||
| 8caeee5d56 | |||
| b26ef29d75 | |||
| fba68de49e | |||
| df04d7dbaf | |||
| 37202e8dbc | |||
| 7cd421b8bc | |||
| 17a0aa40f3 | |||
| b7ae833847 | |||
| a2078cf37c | |||
| 4ebc9007c0 | |||
| f66f5629b3 | |||
| ac007c0698 | |||
| 6b9630ac94 | |||
| e54c7a866d | |||
| 750d949f10 | |||
| 28803fc3b4 | |||
| ad054c855d | |||
| 7d605994f9 | |||
| e7914df1c6 | |||
| 1d687b2170 | |||
| a796c65f64 | |||
| 5e3e440c7f | |||
| ea2ffbe002 | |||
| db9fd45770 | |||
| 770cf14bde | |||
| 00fc8b2580 | |||
| e2adf403d6 | |||
| 35ed15faaf | |||
| 9821b26794 | |||
| 8662b07822 | |||
| 2c3e55b8c9 | |||
| 35cd59e8d5 | |||
| eae6c87114 | |||
| 938549d01a | |||
| 079812ed8a | |||
| ac5434c423 | |||
| 4c46ecfd35 | |||
| 534e4012a4 | |||
| 71fe4a8f36 | |||
| aec0ef46e4 |
+2
-1
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
packages/*/node_modules/
|
||||
packages/*/lib/
|
||||
packages/*/lib/
|
||||
packages/glob/__tests__/_temp
|
||||
+20
-8
@@ -1,28 +1,42 @@
|
||||
{
|
||||
"plugins": ["jest", "@typescript-eslint"],
|
||||
"extends": ["plugin:github/es6"],
|
||||
"extends": ["plugin:github/recommended"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 9,
|
||||
"sourceType": "module",
|
||||
"project": "./tsconfig.json"
|
||||
"project": "./tsconfig.eslint.json"
|
||||
},
|
||||
"rules": {
|
||||
"eslint-comments/no-use": "off",
|
||||
"github/no-then": "off",
|
||||
"import/no-namespace": "off",
|
||||
"no-shadow": "off",
|
||||
"no-unused-vars": "off",
|
||||
"no-undef": "off",
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@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": "error",
|
||||
"@typescript-eslint/class-name-casing": "error",
|
||||
"@typescript-eslint/camelcase": "off",
|
||||
"@typescript-eslint/consistent-type-assertions": "off",
|
||||
"@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/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 +46,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,7 +53,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
## Development
|
||||
# Contributions
|
||||
|
||||
We welcome contributions in the form of issues and pull requests. We view the contributions and process as the same for internal and external contributors.
|
||||
|
||||
## Issues
|
||||
|
||||
Log issues for both bugs and enhancement requests. Logging issues are important for the open community.
|
||||
|
||||
Issues in this repository should be for the toolkit packages. General feedback for GitHub Actions should be filed in the [community forums.](https://github.community/t5/GitHub-Actions/bd-p/actions) Runner specific issues can be filed [in the runner repository](https://github.com/actions/runner).
|
||||
|
||||
## Enhancements and Feature Requests
|
||||
|
||||
We ask that before significant effort is put into code changes, that we have agreement on taking the change before time is invested in code changes.
|
||||
|
||||
1. Create a feature request.
|
||||
2. When we agree to take the enhancement, create an ADR to agree on the details of the change.
|
||||
|
||||
An ADR is an Architectural Decision Record. This allows consensus on the direction forward and also serves as a record of the change and motivation. [Read more here](../docs/adrs/README.md).
|
||||
|
||||
## Development Life Cycle
|
||||
|
||||
This repository uses [Lerna](https://github.com/lerna/lerna#readme) to manage multiple packages. Read the documentation there to begin contributing.
|
||||
|
||||
@@ -37,4 +56,4 @@ This will ask you some questions about the new package. Start with `0.0.0` as th
|
||||
}
|
||||
```
|
||||
|
||||
3. Start developing 😄 and open a pull request.
|
||||
3. Start developing 😄.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Thank you 🙇♀ for wanting to create an issue in this repository. Before you do, please ensure you are filing the issue in the right place. Issues should only be opened on if the issue **relates to code in this repository**.
|
||||
|
||||
* If you have found a security issue [please submit it here](https://hackerone.com/github)
|
||||
* If you have questions about writing workflows or action files, then please [visit the GitHub Community Forum's Actions Board](https://github.community/t5/GitHub-Actions/bd-p/actions)
|
||||
* If you are having an issue or question about GitHub Actions then please [contact customer support](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/about-github-actions#contacting-support)
|
||||
|
||||
If your issue is relevant to this repository, please include the information below:
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Create a request to help us improve
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Thank you 🙇♀ for wanting to create an issue in this repository. Before you do, please ensure you are filing the issue in the right place. Issues should only be opened on if the issue **relates to code in this repository**.
|
||||
|
||||
* If you have found a security issue [please submit it here](https://hackerone.com/github)
|
||||
* If you have questions about writing workflows or action files, then please [visit the GitHub Community Forum's Actions Board](https://github.community/t5/GitHub-Actions/bd-p/actions)
|
||||
* If you are having an issue or question about GitHub Actions then please [contact customer support](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/about-github-actions#contacting-support)
|
||||
|
||||
If your issue is relevant to this repository, please include the information below:
|
||||
|
||||
**Describe the enhancement**
|
||||
A clear and concise description of what the features or enhancement you need.
|
||||
|
||||
**Code Snippet**
|
||||
If applicable, add a code snippet to show the api enhancement.
|
||||
|
||||
**Additional information**
|
||||
Add any other context about the feature here.
|
||||
@@ -0,0 +1,95 @@
|
||||
name: artifact-unit-tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
runs-on: [ubuntu-latest, windows-latest, macos-latest]
|
||||
fail-fast: false
|
||||
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set Node.js 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
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
|
||||
- name: Set env variables
|
||||
uses: ./packages/artifact/__tests__/ci-test-action/
|
||||
|
||||
# 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 artifacts package
|
||||
- name: Install root npm packages
|
||||
run: npm ci
|
||||
|
||||
- name: Compile artifact package
|
||||
run: |
|
||||
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
|
||||
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'], 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: |
|
||||
mkdir artifact-1-directory
|
||||
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: |
|
||||
mkdir multi-artifact-directory
|
||||
node -e "Promise.resolve(require('./packages/artifact/lib/artifact-client').create().downloadAllArtifacts('multi-artifact-directory'))"
|
||||
|
||||
- name: Verify downloadAllArtifacts()
|
||||
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-3/artifact-path/empty.txt" "${{ env.empty-artifact-content }}"
|
||||
@@ -0,0 +1,38 @@
|
||||
name: toolkit-audit
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
name: Audit
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- 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: audit tools (without allow-list)
|
||||
run: npm audit --audit-level=moderate
|
||||
|
||||
- name: audit packages
|
||||
run: npm run audit-all
|
||||
@@ -0,0 +1,91 @@
|
||||
name: cache-unit-tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
runs-on: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
fail-fast: false
|
||||
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set Node.js 16.x
|
||||
uses: actions/setup-node@v3
|
||||
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
|
||||
@@ -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 12.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.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
|
||||
@@ -0,0 +1,37 @@
|
||||
name: "Code Scanning - Action"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0'
|
||||
|
||||
jobs:
|
||||
CodeQL-Build:
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
|
||||
# CodeQL runs on ubuntu-latest, windows-latest, and macos-latest
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: javascript
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
@@ -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 }}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: toolkit-unit-tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
name: Build
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
runs-on: [ubuntu-latest, macos-latest, windows-latest]
|
||||
fail-fast: false
|
||||
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- 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: Compile
|
||||
run: npm run build
|
||||
|
||||
- name: npm test
|
||||
run: npm test -- --runInBand
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Format
|
||||
run: npm run format-check
|
||||
@@ -0,0 +1,45 @@
|
||||
name: "UpdateOctokit"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
UpdateOctokit:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'actions' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Update Octokit
|
||||
working-directory: packages/github
|
||||
run: |
|
||||
npx npm-check-updates -u --dep prod
|
||||
npm install
|
||||
- name: Check Status
|
||||
id: status
|
||||
working-directory: packages/github
|
||||
run: |
|
||||
if [[ "$(git status --porcelain)" != "" ]]; then
|
||||
echo "::set-output name=createPR::true"
|
||||
git config --global user.email "github-actions@github.com"
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git checkout -b bots/updateGitHubDependencies-${{github.run_number}}
|
||||
git add .
|
||||
git commit -m "Update Dependencies"
|
||||
git push --set-upstream origin bots/updateGitHubDependencies-${{github.run_number}}
|
||||
fi
|
||||
- name: Create PR
|
||||
if: ${{steps.status.outputs.createPR}}
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
github.pulls.create(
|
||||
{
|
||||
base: "main",
|
||||
owner: "${{github.repository_owner}}",
|
||||
repo: "toolkit",
|
||||
title: "Update Octokit dependencies",
|
||||
body: "Update Octokit dependencies",
|
||||
head: "bots/updateGitHubDependencies-${{github.run_number}}"
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
name: Main workflow
|
||||
on: [push]
|
||||
jobs:
|
||||
Ubuntu:
|
||||
name: Run Ubuntu
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@master
|
||||
|
||||
- name: Set Node.js 10.x
|
||||
uses: actions/setup-node@master
|
||||
with:
|
||||
version: 10.x
|
||||
|
||||
- name: npm install
|
||||
run: npm install
|
||||
|
||||
- name: Bootstrap
|
||||
run: npm run bootstrap
|
||||
|
||||
- name: Compile
|
||||
run: npm run build
|
||||
|
||||
- name: npm test
|
||||
run: npm test
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Format
|
||||
run: npm run format-check
|
||||
Windows:
|
||||
name: Run Windows
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@master
|
||||
|
||||
- name: Set Node.js 10.x
|
||||
uses: actions/setup-node@master
|
||||
with:
|
||||
version: 10.x
|
||||
|
||||
- name: npm install
|
||||
run: npm install
|
||||
|
||||
- name: Bootstrap
|
||||
run: npm run bootstrap
|
||||
|
||||
- name: Compile
|
||||
run: npm run build
|
||||
|
||||
# TODO: This currently ignores exec due to issues with Node and spawning on Windows, which I think is exacerbated by Jest.
|
||||
# It doesn't seem to affect behavior in actions themselves, just when testing with Jest.
|
||||
# See other similar issues here: https://github.com/nodejs/node/issues/25484
|
||||
- name: npm test
|
||||
run: npm run test-ci
|
||||
+4
-1
@@ -1,4 +1,7 @@
|
||||
node_modules/
|
||||
packages/*/node_modules/
|
||||
packages/*/lib/
|
||||
packages/*/__tests__/_temp/
|
||||
packages/*/__tests__/_temp/
|
||||
.DS_Store
|
||||
*.xar
|
||||
packages/*/audit.json
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
* @actions/actions-runtime
|
||||
|
||||
/packages/artifact/ @actions/artifacts-actions
|
||||
/packages/cache/ @actions/actions-cache
|
||||
@@ -0,0 +1,76 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to make participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socio-economic status, nationality, personal
|
||||
appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all project spaces, and it also applies when
|
||||
an individual is representing the project or its community in public spaces.
|
||||
Examples of representing a project or community include using an official
|
||||
project e-mail address, posting via an official social media account, or acting
|
||||
as an appointed representative at an online or offline event. Representation of
|
||||
a project may be further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at opensource@github.com. All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see
|
||||
https://www.contributor-covenant.org/faq
|
||||
@@ -1,3 +1,5 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright 2019 GitHub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
@@ -3,35 +3,225 @@
|
||||
<img src="res/at-logo.png">
|
||||
</p>
|
||||
|
||||
## Toolkit
|
||||
<p align="center">
|
||||
<a href="https://github.com/actions/toolkit/actions?query=workflow%3Atoolkit-unit-tests"><img alt="Toolkit unit tests status" src="https://github.com/actions/toolkit/workflows/toolkit-unit-tests/badge.svg"></a>
|
||||
<a href="https://github.com/actions/toolkit/actions?query=workflow%3Atoolkit-audit"><img alt="Toolkit audit status" src="https://github.com/actions/toolkit/workflows/toolkit-audit/badge.svg"></a>
|
||||
</p>
|
||||
|
||||
The toolkit provides a set of packages to make creating actions easier and drive consistency.
|
||||
|
||||
## GitHub Actions Toolkit
|
||||
|
||||
The GitHub Actions ToolKit provides a set of packages to make creating actions easier.
|
||||
|
||||
<br/>
|
||||
<h3 align="center">Get started with the <a href="https://github.com/actions/javascript-action">javascript-action template</a>!</h3>
|
||||
<br/>
|
||||
|
||||
## Packages
|
||||
|
||||
The toolkit provides four separate packages. Since actions are run by pulling actions from the github graph, dependencies including the packages are vendored into your action.
|
||||
:heavy_check_mark: [@actions/core](packages/core)
|
||||
|
||||
| Package | Description |
|
||||
| ------- | ----------- |
|
||||
| [@actions/core](packages/core) | Core functions for getting inputs, setting outputs, setting results, logging, secrets and environment variables |
|
||||
| [@actions/exec](packages/exec) | Functions necessary for running tools on the command line |
|
||||
| [@actions/io](packages/io) | Core functions for CLI filesystem scenarios |
|
||||
| [@actions/tool-cache](packages/tool-cache) | Functions necessary for downloading and caching tools |
|
||||
Provides functions for inputs, outputs, results, logging, secrets and variables. Read more [here](packages/core)
|
||||
|
||||
```bash
|
||||
$ npm install @actions/core
|
||||
```
|
||||
<br/>
|
||||
|
||||
:runner: [@actions/exec](packages/exec)
|
||||
|
||||
Provides functions to exec cli tools and process output. Read more [here](packages/exec)
|
||||
|
||||
```bash
|
||||
$ npm install @actions/exec
|
||||
```
|
||||
<br/>
|
||||
|
||||
:ice_cream: [@actions/glob](packages/glob)
|
||||
|
||||
Provides functions to search for files matching glob patterns. Read more [here](packages/glob)
|
||||
|
||||
```bash
|
||||
$ 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)
|
||||
|
||||
```bash
|
||||
$ npm install @actions/io
|
||||
```
|
||||
<br/>
|
||||
|
||||
:hammer: [@actions/tool-cache](packages/tool-cache)
|
||||
|
||||
Provides functions for downloading and caching tools. e.g. setup-* actions. Read more [here](packages/tool-cache)
|
||||
|
||||
See @actions/cache for caching workflow dependencies.
|
||||
|
||||
```bash
|
||||
$ npm install @actions/tool-cache
|
||||
```
|
||||
<br/>
|
||||
|
||||
:octocat: [@actions/github](packages/github)
|
||||
|
||||
Provides an Octokit client hydrated with the context that the current action is being run in. Read more [here](packages/github)
|
||||
|
||||
```bash
|
||||
$ npm install @actions/github
|
||||
```
|
||||
<br/>
|
||||
|
||||
:floppy_disk: [@actions/artifact](packages/artifact)
|
||||
|
||||
Provides functions to interact with actions artifacts. Read more [here](packages/artifact)
|
||||
|
||||
```bash
|
||||
$ npm install @actions/artifact
|
||||
```
|
||||
<br/>
|
||||
|
||||
:dart: [@actions/cache](packages/cache)
|
||||
|
||||
Provides functions to cache dependencies and build outputs to improve workflow execution time. Read more [here](packages/cache)
|
||||
|
||||
```bash
|
||||
$ npm install @actions/cache
|
||||
```
|
||||
<br/>
|
||||
|
||||
## Creating an Action with the Toolkit
|
||||
|
||||
Actions are units of work which can either run in a container or on the host machine.
|
||||
:question: [Choosing an action type](docs/action-types.md)
|
||||
|
||||
[Choosing an action type](docs/action-types.md): Outlines the differences and why you would want to create a host or a container based action.
|
||||
Outlines the differences and why you would want to create a JavaScript or a container based action.
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
[JavaScript Action Walthrough](docs/node12-action.md): Create an action which runs on the host using the toolkit
|
||||
:curly_loop: [Versioning](docs/action-versioning.md)
|
||||
|
||||
[Docker Action Walkthrough](docs/container-action.md): Create an action that is delivered as a container and run with docker.
|
||||
Actions are downloaded and run from the GitHub graph of repos. This contains guidance for versioning actions and safe releases.
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
[Docker Action Walkthrough with Octokit](docs/container-action-toolkit.md): Create an action that is delivered as a container which uses the toolkit. This example uses the GitHub context to construct an Octokit client.
|
||||
:warning: [Problem Matchers](docs/problem-matchers.md)
|
||||
|
||||
[Versioning](docs/action-versioning.md): Recommendations on versioning, releases and tagging your action.
|
||||
Problem Matchers are a way to scan the output of actions for a specified regex pattern and surface that information prominently in the UI.
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
:warning: [Proxy Server Support](docs/proxy-support.md)
|
||||
|
||||
Self-hosted runners can be configured to run behind proxy servers.
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<h3><a href="https://github.com/actions/hello-world-javascript-action">Hello World JavaScript Action</a></h3>
|
||||
|
||||
Illustrates how to create a simple hello world javascript action.
|
||||
|
||||
```javascript
|
||||
...
|
||||
const nameToGreet = core.getInput('who-to-greet');
|
||||
console.log(`Hello ${nameToGreet}!`);
|
||||
...
|
||||
```
|
||||
<br/>
|
||||
|
||||
<h3><a href="https://github.com/actions/javascript-action">JavaScript Action Walkthrough</a></h3>
|
||||
|
||||
Walkthrough and template for creating a JavaScript Action with tests, linting, workflow, publishing, and versioning.
|
||||
|
||||
```javascript
|
||||
async function run() {
|
||||
try {
|
||||
const ms = core.getInput('milliseconds');
|
||||
console.log(`Waiting ${ms} milliseconds ...`)
|
||||
...
|
||||
```
|
||||
```javascript
|
||||
PASS ./index.test.js
|
||||
✓ throws invalid number
|
||||
✓ wait 500 ms
|
||||
✓ test runs
|
||||
|
||||
Test Suites: 1 passed, 1 total
|
||||
Tests: 3 passed, 3 total
|
||||
```
|
||||
<br/>
|
||||
|
||||
<h3><a href="https://github.com/actions/typescript-action">TypeScript Action Walkthrough</a></h3>
|
||||
|
||||
Walkthrough creating a TypeScript Action with compilation, tests, linting, workflow, publishing, and versioning.
|
||||
|
||||
```javascript
|
||||
import * as core from '@actions/core';
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const ms = core.getInput('milliseconds');
|
||||
console.log(`Waiting ${ms} milliseconds ...`)
|
||||
...
|
||||
```
|
||||
```javascript
|
||||
PASS ./index.test.js
|
||||
✓ throws invalid number
|
||||
✓ wait 500 ms
|
||||
✓ test runs
|
||||
|
||||
Test Suites: 1 passed, 1 total
|
||||
Tests: 3 passed, 3 total
|
||||
```
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<h3><a href="docs/container-action.md">Docker Action Walkthrough</a></h3>
|
||||
|
||||
Create an action that is delivered as a container and run with docker.
|
||||
|
||||
```docker
|
||||
FROM alpine:3.10
|
||||
COPY LICENSE README.md /
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
```
|
||||
<br/>
|
||||
|
||||
<h3><a href="https://github.com/actions/container-toolkit-action">Docker Action Walkthrough with Octokit</a></h3>
|
||||
|
||||
Create an action that is delivered as a container which uses the toolkit. This example uses the GitHub context to construct an Octokit client.
|
||||
|
||||
```docker
|
||||
FROM node:slim
|
||||
COPY . .
|
||||
RUN npm install --production
|
||||
ENTRYPOINT ["node", "/lib/main.js"]
|
||||
```
|
||||
```javascript
|
||||
const myInput = core.getInput('myInput');
|
||||
core.debug(`Hello ${myInput} from inside a container`);
|
||||
|
||||
const context = github.context;
|
||||
console.log(`We can even get context data, like the repo: ${context.repo.repo}`)
|
||||
```
|
||||
<br/>
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions. See [how to contribute](docs/contribute.md).
|
||||
We welcome contributions. See [how to contribute](.github/CONTRIBUTING.md).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
See [our code of conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
If you discover a security issue in this repo, please submit it through the [GitHub Security Bug Bounty](https://hackerone.com/github)
|
||||
|
||||
Thanks for helping make GitHub Actions safe for everyone.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Debugging
|
||||
If the job logs do not provide enough detail on why a job may be failing, some other options exist to assist with troubleshooting.
|
||||
|
||||
## Step Debug Logs
|
||||
This is the primary way for customers to debug job failures caused by failed steps.
|
||||
|
||||
Step debug logs increase the verbosity of a job's logs during and after a job's execution to assist with troubleshooting.
|
||||
|
||||
Additional log events with the prefix `::debug::` will now also appear in the job's logs, these log events are provided by the Action's author and the runner process.
|
||||
|
||||
### How to Access Step Debug Logs
|
||||
This flag can be enabled by [setting the secret](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets#creating-encrypted-secrets) `ACTIONS_STEP_DEBUG` to `true`.
|
||||
|
||||
All actions ran while this secret is enabled will show debug events in the [Downloaded Logs](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/managing-a-workflow-run#downloading-logs) and [Web Logs](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/managing-a-workflow-run#viewing-logs-to-diagnose-failures).
|
||||
|
||||
## Runner Diagnostic Logs
|
||||
Runner Diagnostic Logs provide additional log files detailing how the Runner is executing an action.
|
||||
|
||||
You need the runner diagnostic logs only if you think there is an infrastructure problem with GitHub Actions and you want the product team to check the logs.
|
||||
|
||||
Each file contains different logging information that corresponds to that process:
|
||||
* The Runner process coordinates setting up workers to execute jobs.
|
||||
* The Worker process executes the job.
|
||||
|
||||
These files contain the prefix `Runner_` or `Worker_` to indicate the log source.
|
||||
|
||||
### How to Access Runner Diagnostic Logs
|
||||
These log files are enabled by [setting the secret](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets#creating-encrypted-secrets) `ACTIONS_RUNNER_DEBUG` to `true`.
|
||||
|
||||
All actions ran while this secret is enabled contain additional diagnostic log files in the `runner-diagnostic-logs` folder of the [log archive](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/managing-a-workflow-run#downloading-logs).
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
There are two types of actions. JavaScript and Docker actions.
|
||||
|
||||
- **JavaScript Actions**: JavaScript actions run on the host machine. The unit of work is decoupled from the environment.
|
||||
- **Docker Actions**: A container action is a container which carries both the unit of work along with the environment and it's dependencies packaged up as a container.
|
||||
- **Docker Actions**: A container action is a container which carries both the unit of work along with the environment and its dependencies packaged up as a container.
|
||||
|
||||
Both have access to the workspace and the github event payload and context.
|
||||
|
||||
@@ -11,7 +11,7 @@ Both have access to the workspace and the github event payload and context.
|
||||
|
||||
Docker actions carry both the unit of work and the environment.
|
||||
|
||||
This creates a more consistent and reliable unit of work where the consumer of the action does not need to worry about the toolsets and it's dependencies.
|
||||
This creates a more consistent and reliable unit of work where the consumer of the action does not need to worry about the toolsets and its dependencies.
|
||||
|
||||
Docker actions are currently limited to Linux only.
|
||||
|
||||
@@ -19,7 +19,7 @@ Docker actions are currently limited to Linux only.
|
||||
|
||||
JavaScript actions decouple the unit of work from the environment and run directly on the host machine or VM.
|
||||
|
||||
Consider a simple example of testing a node lib on node 8, 10 and running a custom action. Each job will setup a node version on the host and custom-action will run it's unit of work on each environment (node8+ubunut16, node8+windows-2019, etc.)
|
||||
Consider a simple example of testing a node lib on node 8, 10 and running a custom action. Each job will setup a node version on the host and custom-action will run its unit of work on each environment (node8+ubuntu16, node8+windows-2019, etc.)
|
||||
|
||||
```yaml
|
||||
on: push
|
||||
@@ -32,14 +32,14 @@ jobs:
|
||||
os: [ubuntu-16.04, windows-2019]
|
||||
runs-on: ${{matrix.os}}
|
||||
actions:
|
||||
- uses: actions/setup-node@master
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
version: ${{matrix.node}}
|
||||
- run: |
|
||||
npm install
|
||||
- run: |
|
||||
npm test
|
||||
- uses: actions/custom-action@master
|
||||
- uses: actions/custom-action@v1
|
||||
```
|
||||
|
||||
JavaScript actions work on any environment that host action runtime is supported on which is currently node 12. However, a host action that runs a toolset expects the environment that it's running on to have that toolset in it's PATH or using a setup-* action to acquire it on demand.
|
||||
JavaScript actions work on any environment that host action runtime is supported on which is currently node 12. However, a host action that runs a toolset expects the environment that it's running on to have that toolset in its PATH or using a setup-* action to acquire it on demand.
|
||||
|
||||
+48
-28
@@ -1,43 +1,63 @@
|
||||
# Versioning
|
||||
|
||||
Actions are downloaded and run from the GitHub graph of repos. The workflow references an action use a ref.
|
||||
Actions are downloaded and run from the GitHub graph of repos. The workflow references an action using a ref.
|
||||
|
||||
Examples:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- uses: actions/setup-node@74bc508
|
||||
- uses: actions/setup-node@v1
|
||||
- uses: actions/setup-node@master # not recommended
|
||||
- uses: actions/javascript-action@v1 # recommended. starter workflows use this
|
||||
- uses: actions/javascript-action@v1.0.0 # if an action offers specific releases
|
||||
- uses: actions/javascript-action@41775a4da8ffae865553a738ab8ac1cd5a3c0044 # sha
|
||||
```
|
||||
|
||||
Binding to the immutable sha1 of a released version is the safest for stability and security.
|
||||
# Compatibility
|
||||
|
||||
Binding to a specific major version allows for receiving critical fixes and security patches while still mainting compatibility and the assurance your workflow should still work.
|
||||
Binding to a major version is the latest of that major version ( e.g. `v1` == "1.*" )
|
||||
|
||||
Binding to master is convenient but if a new major version is released which breaks compatilibility, your workflow could break.
|
||||
Major versions should guarantee compatibility. A major version can add net new capabilities but should not break existing input compatibility or break existing workflows.
|
||||
|
||||
Major version binding allows you to take advantage of bug fixes and critical functionality and security fixes. The `main` branch has the latest code and is unstable to bind to since changes get committed to main and released to the market place by creating a tag. In addition, a new major version carrying breaking changes will get implemented in main after branching off the previous major version.
|
||||
|
||||
> Warning: do not reference `main` since that is the latest code and can be carrying breaking changes of the next major version.
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- uses: actions/javascript-action@main # do not do this
|
||||
```
|
||||
|
||||
Binding to the immutable full sha1 may offer more reliability. However, note that the hosted images toolsets (e.g. ubuntu-latest) move forward and if there is a tool breaking issue, actions may react with a patch to a major version to compensate so binding to a specific SHA may prevent you from getting fixes.
|
||||
|
||||
> Recommendation: bind to major versions to get functionality and fixes but reserve binding to a specific release or SHA as a mitigation strategy for unforeseen breaks.
|
||||
|
||||
# Recommendations
|
||||
|
||||
1. **Don't check node_modules into master**: This will discourage people from attaching to master since the action will fail. You can enforce this by including `node_modules` in your `.gitignore` file.
|
||||
2. **Create a release branch for each major version**: This will act as an alpha release for that major version. Any time you are ready to publish a new version from master, you should pull those changes into this branch (following the same steps listed below).
|
||||
```
|
||||
git checkout -b releases/v1 # If this branch already exists, omit the -b flag
|
||||
rm -rf node_modules
|
||||
sed -i '/node_modules/d' .gitignore # Bash command that removes node_modules from .gitignore
|
||||
npm install --production
|
||||
git add node_modules .gitignore
|
||||
git commit -m node_modules
|
||||
git push origin releases/v1
|
||||
```
|
||||
3. **When ready for a stable release, add a major version tag**: Move the major version tag (v1, v2, etc.) to point to the ref of the current release. This will act as the stable release for that major version. You should keep this tag updated to the most recent stable minor/patch release.
|
||||
```
|
||||
git checkout releases/v1
|
||||
git push origin :refs/tags/v1
|
||||
git tag -fa v1 -m "Update v1 tag"
|
||||
git push origin v1
|
||||
```
|
||||
4. **Create releases for minor and patch version updates**: From the GitHub UI create a release for each minor or patch version update titled with that release version (e.g. v1.2.3).
|
||||
5. **Compatibility Breaks**: introduce a new major version branch (releases/v2) and tag (v2) if changes will break existing workflows. For example, changing inputs.
|
||||
1. **Create a GitHub release for each specific version**: Creating a release like [ v1.0.0 ](https://github.com/actions/javascript-action/releases/tag/v1.0.0) allows users to bind back to a specific version if an issue is encountered with the latest major version.
|
||||
|
||||
See [Git-Basics-Tagging](https://git-scm.com/book/en/v2/Git-Basics-Tagging)
|
||||
2. **Publish the specific version to the marketplace**: When you release a specific version, choose the option to "Publish this Action to the GitHub Marketplace".
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/33549821/78670739-36f5ae00-78ac-11ea-9660-57d5687ce520.png" alt="screenshot" height="250"/>
|
||||
|
||||
3. **Make the new release available to those binding to the major version tag**: Move the major version tag (v1, v2, etc.) to point to the ref of the current release. This will act as the stable release for that major version. You should keep this tag updated to the most recent stable minor/patch release.
|
||||
|
||||
```
|
||||
git tag -fa v1 -m "Update v1 tag"
|
||||
git push origin v1 --force
|
||||
```
|
||||
# Major Versions
|
||||
|
||||
All releases for a major version should hold compat including input compatibility and behavior compatibility.
|
||||
|
||||
Introduce a major version for compatibility breaks and major rewrites of the action.
|
||||
|
||||
Ideally, a major version would carry other benefits to the user to entice them to upgrade their workflows. Since updating their workflows will need to be done with an understanding of the changes and what compatibility was broken, introducing a new major version shouldn't be taken lightly.
|
||||
|
||||
To get feedback and to set expectations, the new major version can be initially released with `v2-beta` tag to indicate you can try it out but it's still going under some churn. Upon release the `-beta` can be dropped and there's an expectation of compatibility from that point forward.
|
||||
|
||||
[An example of v2-beta with checkout](https://github.com/actions/checkout/tree/c170eefc2657d93cc91397be50a299bff978a052#checkout-v2-beta)
|
||||
|
||||
# Sample Workflow
|
||||
|
||||
This illustrates the versioning workflow covered above.
|
||||
|
||||

|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# ADR 381: `glob` module
|
||||
|
||||
**Date**: 2019-12-05
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
This ADR proposes adding a `glob` function to the toolkit.
|
||||
|
||||
First party actions should have a consistent glob experience.
|
||||
|
||||
Related to artifact upload/download v2.
|
||||
|
||||
## Decision
|
||||
|
||||
### New module
|
||||
|
||||
Create a new module `@actions/glob` that can be versioned at it's own pace - not tied to `@actions/io`.
|
||||
|
||||
### Signature
|
||||
|
||||
```js
|
||||
/**
|
||||
* Constructs a globber from patterns
|
||||
*
|
||||
* @param patterns Patterns separated by newlines
|
||||
* @param options Glob options
|
||||
*/
|
||||
export function create(
|
||||
patterns: string,
|
||||
options?: GlobOptions
|
||||
): Promise<Globber> {}
|
||||
|
||||
/**
|
||||
* Used to match files and directories
|
||||
*/
|
||||
export interface Globber {
|
||||
/**
|
||||
* Returns the search path preceding the first glob segment, from each pattern.
|
||||
* Duplicates and descendants of other paths are filtered out.
|
||||
*
|
||||
* Example 1: The patterns `/foo/*` and `/bar/*` returns `/foo` and `/bar`.
|
||||
*
|
||||
* Example 2: The patterns `/foo/*` and `/foo/bar/*` returns `/foo`.
|
||||
*/
|
||||
getSearchPaths(): string[]
|
||||
|
||||
/**
|
||||
* Returns files and directories matching the glob patterns.
|
||||
*
|
||||
* Order of the results is not guaranteed.
|
||||
*/
|
||||
glob(): Promise<string[]>
|
||||
|
||||
/**
|
||||
* Returns files and directories matching the glob patterns.
|
||||
*
|
||||
* Order of the results is not guaranteed.
|
||||
*/
|
||||
globGenerator(): AsyncGenerator<string, void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Options to control globbing behavior
|
||||
*/
|
||||
export interface GlobOptions {
|
||||
/**
|
||||
* Indicates whether to follow symbolic links. Generally should set to false
|
||||
* when deleting files.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
followSymbolicLinks?: boolean
|
||||
|
||||
/**
|
||||
* Indicates whether directories that match a glob pattern, should implicitly
|
||||
* cause all descendant paths to be matched.
|
||||
*
|
||||
* For example, given the directory `my-dir`, the following glob patterns
|
||||
* would produce the same results: `my-dir/**`, `my-dir/`, `my-dir`
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
implicitDescendants?: boolean
|
||||
|
||||
/**
|
||||
* Indicates whether broken symbolic should be ignored and omitted from the
|
||||
* result set. Otherwise an error will be thrown.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
omitBrokenSymbolicLinks?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
### Toolkit usage
|
||||
|
||||
Example, do not follow symbolic links:
|
||||
|
||||
```js
|
||||
const patterns = core.getInput('path')
|
||||
const globber = glob.create(patterns, {followSymbolicLinks: false})
|
||||
const files = globber.glob()
|
||||
```
|
||||
|
||||
Example, iterator:
|
||||
|
||||
```js
|
||||
const patterns = core.getInput('path')
|
||||
const globber = glob.create(patterns)
|
||||
for await (const file of this.globGenerator()) {
|
||||
console.log(file)
|
||||
}
|
||||
```
|
||||
|
||||
### Action usage
|
||||
|
||||
Actions should follow symbolic links by default.
|
||||
|
||||
Users can opt-out.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
build:
|
||||
steps:
|
||||
- uses: actions/upload-artifact@v1
|
||||
with:
|
||||
path: |
|
||||
**/*.tar.gz
|
||||
**/*.pkg
|
||||
follow-symbolic-links: false # opt out, should default to true
|
||||
```
|
||||
|
||||
### HashFiles function
|
||||
|
||||
Hash files should not follow symbolic links by default.
|
||||
|
||||
User can opt-in by specifying flag `--follow-symbolic-links`.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
build:
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
hash: ${{ hashFiles('--follow-symbolic-links', '**/package-lock.json') }}
|
||||
```
|
||||
|
||||
### Glob behavior
|
||||
|
||||
Patterns `*`, `?`, `[...]`, `**` (globstar) are supported.
|
||||
|
||||
With the following behaviors:
|
||||
|
||||
- File names that begin with `.` may be included in the results
|
||||
- Case insensitive on Windows
|
||||
- Directory separator `/` and `\` both supported on Windows
|
||||
|
||||
Note:
|
||||
- Refer [here](https://www.gnu.org/software/bash/manual/html_node/Pattern-Matching.html#Pattern-Matching) for more information about Bash glob patterns.
|
||||
- Refer [here](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html) for more information about Bash glob options.
|
||||
|
||||
### Tilde expansion
|
||||
|
||||
Support basic tilde expansion, for current user HOME replacement only.
|
||||
|
||||
For example, on macOS:
|
||||
- `~` may expand to `/Users/johndoe`
|
||||
- `~/foo` may expand to `/Users/johndoe/foo`
|
||||
|
||||
Note:
|
||||
- Refer [here](https://www.gnu.org/software/bash/manual/html_node/Tilde-Expansion.html) for more information about Bash tilde expansion.
|
||||
- All other forms of tilde expansion are not supported.
|
||||
- Use `os.homedir()` to resolve the HOME path
|
||||
|
||||
### Root and normalize paths
|
||||
|
||||
An unrooted pattern will be rooted using the current working directory, prior to searching. Additionally the search path will be normalized prior to searching (relative pathing removed, slashes normalized on Windows, extra slashes removed).
|
||||
|
||||
The two side effects are:
|
||||
1. Rooted and normalized paths are always returned
|
||||
2. The pattern `**` will include the working directory in the results
|
||||
|
||||
These side effects diverge from Bash behavior. Whereas Bash is designed to be a shell, we are designing an API. This decision is intended to improve predictability of the API results.
|
||||
|
||||
Note:
|
||||
- In Bash, the results are not rooted when the pattern is relative.
|
||||
- In Bash, the results are not normalized. For example, the results from `./*` may look like: `./foo ./bar`
|
||||
- In Bash, the results from the pattern `**` does not include the working directory. However the results from `/foo/**` would include the directory `/foo`. Also the results from `foo/**` would include the directory `foo`.
|
||||
|
||||
## Comments
|
||||
|
||||
Patterns that begin with `#` are treated as comments.
|
||||
|
||||
## Exclude patterns
|
||||
|
||||
Leading `!` changes the meaning of an include pattern to exclude.
|
||||
|
||||
Note:
|
||||
- Multiple leading `!` flips the meaning.
|
||||
|
||||
## Escaping
|
||||
|
||||
Wrapping special characters in `[]` can be used to escape literal glob characters in a file name. For example the literal file name `hello[a-z]` can be escaped as `hello[[]a-z]`.
|
||||
|
||||
On Linux/macOS `\` is also treated as an escape character.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Publish new module `@actions/glob`
|
||||
- Publish docs for the module (add link from `./README.md` to new doc `./packages/glob/README.md`)
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADRs
|
||||
|
||||
ADR, short for "Architecture Decision Record" is a way of capturing important architectural decisions, along with their context and consequences.
|
||||
|
||||
This folder includes ADRs for the actions toolkit. ADRs are proposed in the form of a pull request, and they commonly follow this format:
|
||||
|
||||
* **Title**: short present tense imperative phrase, less than 50 characters, like a git commit message.
|
||||
|
||||
* **Status**: proposed, accepted, rejected, deprecated, superseded, etc.
|
||||
|
||||
* **Context**: what is the issue that we're seeing that is motivating this decision or change.
|
||||
|
||||
* **Decision**: what is the change that we're actually proposing or doing.
|
||||
|
||||
* **Consequences**: what becomes easier or more difficult to do because of this change.
|
||||
|
||||
---
|
||||
|
||||
- More information about ADRs can be found [here](https://github.com/joelparkerhenderson/architecture_decision_record).
|
||||
@@ -0,0 +1 @@
|
||||
<mxfile modified="2019-12-12T18:56:00.899Z" host="www.draw.io" agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36" etag="CISsL8yLQ-3TSrXBbF_M" version="12.3.8" type="device" pages="1"><diagram name="Page-1" id="ff44883e-f642-bcb2-894b-16b3d25a3f0b">7VvbctsqFP0aP9YjkISkx9hJcx7amZ7mTNs8dYiEZVpZeBC+9esPyMi64UsdW3Ymdh4Mm5tgrbU3AqdnDyfLR46n488sIkkPWtGyZ9/3IASWi+SXsqzWFhf5a0PMaaQrlYYn+ocULbV1RiOS1SoKxhJBp3VjyNKUhKJmw5yzRb3aiCX1Uac4Ji3DU4iTwtp3S/t3GomxtgMUlAX/EBqP9eA+1FN+weHvmLNZqkfsQXuUf9bFE1z0paeajXHEFhWT/dCzh5wxsU5NlkOSqNUtFq5oJ1bF0/bswVhMEpkBMpkXf9zSGBzSWE6Ok1RUh9vW38h6CcMgsC2HoJGLPwSt/kkkV1ZnU5bKr0E443MS6RErg2cCc6HJIJlgD0ga3Sk0ZT5McJbRcG38SJOijcxVW2SCs99kyBLG8+FtK/9sSgos1bRHLN2MBvOuBF/92PQrM88yY0kq6Oy94re1ya2K3JKKH5V0pZXMlY1UpmjTXucCGTbjITEvLtRKwDwmwlxF96IWvdKtxu6RsAmRTy4rcJJgQed1EWCtmnhTb9P0C6PyQaGlFe4UQtX69jyr3sV6GrpVyR+ZqDxGacpZdRjDADo1xfaxaSdnTNjvRH7Dsgqxnquk28Kyoxhjd0MH5NfpgNwGHdaUPQsd1gPNcTIrHFqLHkki44ZixWJMBXma4ny5FjJ01cmRe+2cNTn4hWMGJr+C8k+LI06dI7bqCCc0TpULk8gRripIwlW6GrnqbxfEc8IFWe5EUJfCBhJuIcxFGcSKKuNq+LK2Y15D62+gaUeqa1PqHi99bEQ4Sqtov3eH3cjZDRoksjuTM2zJud/vX1TR4MKKdrzrUbR9Xc720tB4Th0aH14OGucGTQ2apgu7ZBx0bnHwL+Kgsz8Oom7ioGdfLA6im5yrckbO5eQc/+T3377+Gz/Mp9/++5SxT+Hwg/G1AyVy1MGLTMR5guM0lM92V5TIgTaF8htPFE7pS6a+JFMJzkgm68zbZzQSs6lKhjhJ2Ezsh3xKOJUTVbAUjb6UpsGUZVRQlsJcxcA/kc9FjUMBpw0SgF2idNuv1AACln25DYsRoFtk3BUZd3F6V3js6NTHQ6DvAdeDvufK2OgFdW45xtI9B4Tto6XGRg6hs8Vg43L7+x19yAkWZL2eyon3lKoGOUbxVvdvgT7o25u+eCUw1Hvf2UnnsULCeBJXFDjNWAHasQJ1GtGtW7DYhdDlt1xtrr+xYHG80y/IeW6X7lsNbwsbaB7qtv3GqRUC3bptELTUPMGZEk2TQ1IOokmVqkI1r6pK06aWGpW4qHShd7pgQqMo2eYt6i4ip5d+qIbyFVgn2f017uyQZdieewZFg2A7jV6laNh2ubvfg94PVm5jo24f+CrV1OvpsDrkoq28rtfLHuFsbPLGBy/ZXodXWRDXsB6F7ZV+ETSu1bymOzvULwLg7enpdI4xcFf8bjX8NcOfvybsz+Pz/XffcMFysv2s1bfe3H7WLvezLXUaCLn93rVx9uEEBsGeaz9rBNp09nEKoOvnVvAkqLddydtA3XP9K0PddA10HtTBu0U9gAdovWBGJ6i753PqoA/frVNH1rU59fa10ASnqxyoZYmt3D1bhl9NXOPu+fUgOY5bA8kFBpDsM22VjSB5LZCGcs6YpgoXMVbSk5tRkqlt4QT/kgudL0Em+XulmJ0iVDbPcQ3HSYHbIUqG874RVSKi6QamjPA5DWkay7S+zoMWG6mJczKnbJa9JwiDxqtLYFKa6UjwCAxltvwJ/Prdp/xPA/vhfw==</diagram></mxfile>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,217 @@
|
||||
# :: Commands
|
||||
|
||||
The [core toolkit package](https://github.com/actions/toolkit/tree/main/packages/core) offers a number of convenience functions for
|
||||
setting results, logging, registering secrets and exporting variables across actions. Sometimes, however, its useful to be able to do
|
||||
these things in a script or other tool.
|
||||
|
||||
To allow this, we provide a special `::` syntax which, if logged to `stdout` on a new line, will allow the runner to perform special behavior on
|
||||
your commands. The following commands are all supported:
|
||||
|
||||
### Set outputs
|
||||
|
||||
To set an output for the step, use `::set-output`:
|
||||
|
||||
```sh
|
||||
echo "::set-output name=FOO::BAR"
|
||||
```
|
||||
|
||||
Running `steps.[step-id].outputs.FOO` in your Yaml will now give you `BAR`
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- name: Set the value
|
||||
id: step_one
|
||||
run: echo "::set-output name=FOO::BAR"
|
||||
- name: Use it
|
||||
run: echo ${{ steps.step_one.outputs.FOO }}
|
||||
```
|
||||
|
||||
This is wrapped by the core setOutput method:
|
||||
|
||||
```javascript
|
||||
export function setOutput(name: string, value: string): void {}
|
||||
```
|
||||
|
||||
### Register a secret
|
||||
|
||||
If a script or action does work to create a secret at runtime, it can be registered with the runner to be masked in logs.
|
||||
|
||||
To mask a value in the logs, use `::add-mask`:
|
||||
|
||||
```sh
|
||||
echo "::add-mask::mysecretvalue"
|
||||
```
|
||||
|
||||
This is wrapped by the core setSecret method
|
||||
|
||||
```javascript
|
||||
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 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 collapsible region up to the next endgroup command.
|
||||
|
||||
```bash
|
||||
echo "::group::my title"
|
||||
echo "::endgroup::"
|
||||
```
|
||||
|
||||
This is wrapped by the core methods:
|
||||
|
||||
```javascript
|
||||
function startGroup(name: string): void {}
|
||||
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
|
||||
echo "::add-matcher::eslint-compact-problem-matcher.json"
|
||||
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.
|
||||
|
||||
```bash
|
||||
echo "::save-state name=FOO::foovalue"
|
||||
```
|
||||
|
||||
Because `save-state` prepends the string `STATE_` to the name, the environment variable `STATE_FOO` will be available to use in the post or main action. See [Sending Values to the pre and post actions](https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#sending-values-to-the-pre-and-post-actions) for more information.
|
||||
|
||||
### Log Level
|
||||
|
||||
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.
|
||||
|
||||
```bash
|
||||
echo "::echo::on"
|
||||
```
|
||||
|
||||
You can also disable echoing.
|
||||
|
||||
```bash
|
||||
echo "::echo::off"
|
||||
```
|
||||
|
||||
This is wrapped by the core method:
|
||||
|
||||
```javascript
|
||||
function setCommandEcho(enabled: boolean): void {}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
### Set an environment variable
|
||||
|
||||
To set an environment variable for future out of process steps, write to the file located at `GITHUB_ENV` or use the equivalent `actions/core` function
|
||||
|
||||
```sh
|
||||
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`.
|
||||
|
||||
```
|
||||
steps:
|
||||
- name: Set the value
|
||||
id: step_one
|
||||
run: |
|
||||
echo 'JSON_RESPONSE<<EOF' >> $GITHUB_ENV
|
||||
curl https://httpbin.org/json >> $GITHUB_ENV
|
||||
echo 'EOF' >> $GITHUB_ENV
|
||||
```
|
||||
|
||||
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}
|
||||
{DELIMETER}
|
||||
```
|
||||
|
||||
This is wrapped by the core `exportVariable` method which sets for future steps but also updates the variable for this step.
|
||||
|
||||
```javascript
|
||||
export function exportVariable(name: string, val: string): void {}
|
||||
```
|
||||
|
||||
### PATH Manipulation
|
||||
|
||||
To prepend a string to PATH write to the file located at `GITHUB_PATH` or use the equivalent `actions/core` function
|
||||
|
||||
```sh
|
||||
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 {}
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
@@ -14,11 +14,11 @@ Complete creating your repo and clone the repo.
|
||||
|
||||
> NOTE: The location of the repo will be how users will reference your action in their workflow file with the using keyword.
|
||||
|
||||
e.g. To use https://github.com/actions/setup-node, user's will author:
|
||||
e.g. To use https://github.com/actions/setup-node, users will author:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
using: actions/setup-node@master
|
||||
using: actions/setup-node@v3
|
||||
```
|
||||
|
||||
# Define Metadata
|
||||
@@ -64,7 +64,7 @@ $ git push
|
||||
|
||||
The runner will download the action and build the docker container on the fly at runtime.
|
||||
|
||||
> Consider versioning your actions with tags. See [versioning](docs/action-versioning.md)
|
||||
> Consider versioning your actions with tags. See [versioning](/docs/action-versioning.md)
|
||||
|
||||
|
||||
|
||||
|
||||
+265
-77
@@ -1,100 +1,288 @@
|
||||
# Github Package
|
||||
# Creating an Action using the GitHub Context
|
||||
|
||||
In order to support using actions to interact with GitHub, I propose adding a `github` package to the toolkit.
|
||||
## Goal
|
||||
|
||||
Its main purpose will be to provide a hydrated GitHub context/Octokit client with some convenience functions. It is largely pulled from the GitHub utilities provided in https://github.com/JasonEtco/actions-toolkit, though it has been condensed.
|
||||
In this walkthrough we will learn how to build a basic action using GitHub context data to greet users when they open an issue or PR. In the process we will explore how to access this context and how to make authenticated requests to the GitHub API.
|
||||
|
||||
### Spec
|
||||
Note that a complete version of this action can be found at https://github.com/damccorm/issue-greeter.
|
||||
|
||||
##### interfaces.ts
|
||||
## Prerequisites
|
||||
|
||||
This walkthrough assumes that you have gone through the basic [javascript action walkthrough](https://github.com/actions/javascript-action) and have a basic action set up. If not, we recommend you go through that first.
|
||||
|
||||
## Installing dependencies
|
||||
|
||||
All of the dependencies we need should come packaged for us in this library's core and github packages. To install, run the following in your action:
|
||||
|
||||
`npm install @actions/core && npm install @actions/github`
|
||||
|
||||
## Metadata
|
||||
|
||||
Next, we will need a welcome message and a repo token as an input. Recall that inputs are defined in the `action.yml` metadata file - update your `action.yml` file to define `welcome-message` and `repo-token` as inputs.
|
||||
|
||||
```yaml
|
||||
name: "Welcome"
|
||||
description: "A basic welcome action"
|
||||
author: "GitHub"
|
||||
inputs:
|
||||
welcome-message:
|
||||
description: "Message to display when a user opens an issue or PR"
|
||||
default: "Thanks for opening an issue! Make sure you've followed CONTRIBUTING.md"
|
||||
repo-token:
|
||||
description: "Token for the repo. Can be passed in using {{ secrets.GITHUB_TOKEN }}"
|
||||
required: true
|
||||
runs:
|
||||
using: "node12"
|
||||
main: "lib/main.js"
|
||||
```
|
||||
/*
|
||||
* Interfaces
|
||||
*/
|
||||
|
||||
export interface PayloadRepository {
|
||||
[key: string]: any
|
||||
full_name?: string
|
||||
name: string
|
||||
owner: {
|
||||
[key: string]: any
|
||||
login: string
|
||||
name?: string
|
||||
}
|
||||
html_url?: string
|
||||
|
||||
## Action logic
|
||||
|
||||
Now that we've installed our dependencies and defined our inputs, we're ready to start writing the action logic in `src/main.ts`! For clarity, we'll structure our action up as follows:
|
||||
|
||||
```ts
|
||||
import * as core from '@actions/core';
|
||||
import * as github from '@actions/github';
|
||||
|
||||
export async function run() {
|
||||
try {
|
||||
const welcomeMessage: string = core.getInput('welcome-message');
|
||||
// TODO - Get context data
|
||||
|
||||
// TODO - make request to the GitHub API to comment on the issue
|
||||
}
|
||||
catch (error) {
|
||||
core.setFailed(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebhookPayloadWithRepository {
|
||||
[key: string]: any
|
||||
repository?: PayloadRepository
|
||||
issue?: {
|
||||
[key: string]: any
|
||||
number: number
|
||||
html_url?: string
|
||||
body?: string
|
||||
}
|
||||
pull_request?: {
|
||||
[key: string]: any
|
||||
number: number
|
||||
html_url?: string
|
||||
body?: string
|
||||
}
|
||||
sender?: {
|
||||
[key: string]: any
|
||||
type: string
|
||||
}
|
||||
action?: string
|
||||
installation?: {
|
||||
id: number
|
||||
[key: string]: any
|
||||
}
|
||||
run();
|
||||
```
|
||||
|
||||
### Getting context data
|
||||
|
||||
For the purpose of this walkthrough, we will need the following pieces of context data:
|
||||
|
||||
- the name of the repo that the action is being run on
|
||||
- the organization/owner of that repo
|
||||
- the number of the issue that has been opened
|
||||
|
||||
Fortunately, the GitHub package provides all of this to us with [a single convenience function](https://github.com/actions/toolkit/blob/ac007c06984bc483fae2ba649788dfc858bc6a8b/packages/github/src/context.ts#L34), so we can simply do:
|
||||
|
||||
`const issue: {owner: string; repo: string; number: number} = github.context.issue;`
|
||||
|
||||
The context object also contains a number of easily accessed properties, as well as easy access to the full [GitHub payload](https://developer.github.com/v3/activity/events/types/). We can use this to check and make sure we're actually looking at a recently opened issue (and not something else, like a comment on an existing issue):
|
||||
|
||||
```ts
|
||||
if (github.context.payload.action !== 'opened') {
|
||||
console.log('No issue or PR was opened, skipping');
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
##### context.ts
|
||||
Our whole `src/main.ts` file now looks like:
|
||||
|
||||
Contains a GitHub context
|
||||
```ts
|
||||
import * as core from '@actions/core';
|
||||
import * as github from '@actions/github';
|
||||
|
||||
```
|
||||
export class Context {
|
||||
/**
|
||||
* Webhook payload object that triggered the workflow
|
||||
*/
|
||||
public payload: WebhookPayloadWithRepository
|
||||
export async function run() {
|
||||
try {
|
||||
const welcomeMessage: string = core.getInput('welcome-message', {required: true});
|
||||
const repoToken: string = core.getInput('repo-token', {required: true});
|
||||
const issue: {owner: string; repo: string; number: number} = github.context.issue;
|
||||
|
||||
/**
|
||||
* Name of the event that triggered the workflow
|
||||
*/
|
||||
public event: string
|
||||
public sha: string
|
||||
public ref: string
|
||||
public workflow: string
|
||||
public action: string
|
||||
public actor: string
|
||||
|
||||
/**
|
||||
* Hydrate the context from the environment
|
||||
*/
|
||||
constructor ()
|
||||
|
||||
public get issue ()
|
||||
|
||||
public get repo ()
|
||||
if (github.context.payload.action !== 'opened') {
|
||||
console.log('No issue or pull request was opened, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO - make request to the GitHub API to comment on the issue
|
||||
}
|
||||
catch (error) {
|
||||
core.setFailed(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
```
|
||||
|
||||
##### github.ts
|
||||
### Sending requests to the GitHub API
|
||||
|
||||
Contains a hydrated Octokit client
|
||||
Now that we have our context data, we are able to send a request to the GitHub API using the [Octokit REST client](https://github.com/octokit/rest.js). The REST client exposes a number of easy convenience functions, including one for adding comments to issues/PRs (issues and PRs are treated as one concept by the Octokit client):
|
||||
|
||||
```ts
|
||||
const client: github.GitHub = new github.GitHub(repoToken);
|
||||
await client.issues.createComment({
|
||||
owner: issue.owner,
|
||||
repo: issue.repo,
|
||||
issue_number: issue.number,
|
||||
body: welcomeMessage
|
||||
});
|
||||
```
|
||||
export class GithubClient extends Octokit {
|
||||
// For making GraphQL requests
|
||||
public graphql: (query: string, variables?: Variables) => Promise<GraphQlQueryResponse>
|
||||
|
||||
// Calls super and initializes graphql
|
||||
constructor (token: string)
|
||||
|
||||
For more docs on the client, you can visit the [Octokit REST documentation](https://octokit.github.io/rest.js/). Now our action code should be complete:
|
||||
|
||||
```ts
|
||||
import * as core from '@actions/core';
|
||||
import * as github from '@actions/github';
|
||||
|
||||
export async function run() {
|
||||
try {
|
||||
const welcomeMessage: string = core.getInput('welcome-message', {required: true});
|
||||
const repoToken: string = core.getInput('repo-token', {required: true});
|
||||
const issue: {owner: string; repo: string; number: number} = github.context.issue;
|
||||
|
||||
if (github.context.payload.action !== 'opened') {
|
||||
console.log('No issue or pull request was opened, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const client: github.GitHub = new github.GitHub(repoToken);
|
||||
await client.issues.createComment({
|
||||
owner: issue.owner,
|
||||
repo: issue.repo,
|
||||
issue_number: issue.number,
|
||||
body: welcomeMessage
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
core.setFailed(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
```
|
||||
|
||||
## Writing unit tests for your action
|
||||
|
||||
Next, we're going to write a basic unit test for our action using jest. If you followed the [javascript walkthrough](https://github.com/actions/javascript-action), you should have a file `__tests__/main.test.ts` that runs tests when `npm test` is called. We're going to start by populating that with one test:
|
||||
|
||||
```ts
|
||||
const nock = require('nock');
|
||||
const path = require('path');
|
||||
|
||||
describe('action test suite', () => {
|
||||
it('It posts a comment on an opened issue', async () => {
|
||||
// TODO
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
For the purposes of this walkthrough, we'll focus on populating this test and leave the remaining test coverage as an exercise for the reader.
|
||||
|
||||
### Mocking inputs
|
||||
|
||||
First, we want to make sure that we can mock our inputs (welcome-message, and repo-token). Actions handles inputs by populating process.env.INPUT_${input name in all caps}, so we can mock that simply by setting those environment variables:
|
||||
|
||||
```ts
|
||||
const nock = require('nock');
|
||||
const path = require('path');
|
||||
|
||||
describe('action test suite', () => {
|
||||
it('It posts a comment on an opened issue', async () => {
|
||||
const welcomeMessage = 'hello';
|
||||
const repoToken = 'token';
|
||||
process.env['INPUT_WELCOME-MESSAGE'] = welcomeMessage;
|
||||
process.env['INPUT_REPO-TOKEN'] = repoToken;
|
||||
|
||||
// TODO
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking the GitHub context
|
||||
|
||||
Mocking the GitHub context is relatively straightforward. Since most of it is simply populated by environment variables, you can just set the corresponding environment variables defined [here](https://github.com/actions/toolkit/blob/ac007c06984bc483fae2ba649788dfc858bc6a8b/packages/github/src/context.ts#L23) and test that it works in that environment. In this case, we can setup our test with:
|
||||
|
||||
```ts
|
||||
const nock = require('nock');
|
||||
const path = require('path');
|
||||
|
||||
describe('action test suite', () => {
|
||||
it('It posts a comment on an opened issue', async () => {
|
||||
const welcomeMessage = 'hello';
|
||||
const repoToken = 'token';
|
||||
process.env['INPUT_WELCOME-MESSAGE'] = welcomeMessage;
|
||||
process.env['INPUT_REPO-TOKEN'] = repoToken;
|
||||
|
||||
process.env['GITHUB_REPOSITORY'] = 'foo/bar';
|
||||
process.env['GITHUB_EVENT_PATH'] = path.join(__dirname, 'payload.json');
|
||||
|
||||
// TODO
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Note that the payload is loaded from GITHUB_EVENT_PATH. Since we set that to `path.join(__dirname, 'payload.json')`, we need to go save our payload there. For the purposes of this test, we can simply save the following to `__tests__/payload.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"issue": {
|
||||
"number": 10
|
||||
},
|
||||
"action": "opened"
|
||||
}
|
||||
```
|
||||
|
||||
Now, calling `github.context.issue` should return `{owner: foo, repo: bar, number: 10}`, and `github.context.payload.action` should get set to 'opened'
|
||||
|
||||
> One important detail here is that because the GitHub context loads these environment variables as soon as it is required, you should set them before you require your action. In most cases, this means you need to rerequire your action in every test. If this is a problem, you can get around it by mocking the class directly using jest (or whatever framework you choose).
|
||||
|
||||
### Mocking the Octokit Client
|
||||
|
||||
To mock the client calls, we recommend using [nock](https://github.com/nock/nock) which allows you to mock the http requests made by the client. First, install nock with `npm install nock --save-dev`.
|
||||
|
||||
For this test, we expect the following call:
|
||||
|
||||
```ts
|
||||
client.issues.createComment({
|
||||
owner: 'foo',
|
||||
repo: 'bar',
|
||||
issue_number: 10,
|
||||
body: 'you posted your first issue'
|
||||
});
|
||||
```
|
||||
|
||||
From [the GitHub endpoint docs](https://developer.github.com/v3/issues/comments/#create-a-comment), we expect this to get make a POST request to `https://api.github.com/repos/foo/bar/issues/10/comments` with body of `{"body":"hello"}`
|
||||
|
||||
We can mock this with:
|
||||
|
||||
```ts
|
||||
const nock = require('nock');
|
||||
const path = require('path');
|
||||
|
||||
describe('action test suite', () => {
|
||||
it('It posts a comment on an opened issue', async () => {
|
||||
const welcomeMessage = 'hello';
|
||||
const repoToken = 'token';
|
||||
process.env['INPUT_WELCOME-MESSAGE'] = welcomeMessage;
|
||||
process.env['INPUT_REPO-TOKEN'] = repoToken;
|
||||
|
||||
process.env['GITHUB_REPOSITORY'] = 'foo/bar';
|
||||
process.env['GITHUB_EVENT_PATH'] = path.join(__dirname, 'payload.json');
|
||||
|
||||
nock('https://api.github.com')
|
||||
.persist()
|
||||
.post('/repos/foo/bar/issues/10/comments', '{\"body\":\"hello\"}')
|
||||
.reply(200);
|
||||
|
||||
const main = require('../src/main');
|
||||
|
||||
await main.run();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
This will fail if the url or body doesn't exactly match the parameters passed into the nock function. We can now run `npm test` and the test should succeed.
|
||||
|
||||
## Build and publish
|
||||
|
||||
Now that we've written and unit tested our action, we can build our action with `npm run build` and push it to a repo where it can be consumed by workflows. For more info on versioning your action, see [our versioning docs](./action-versioning.md).
|
||||
|
||||
## Next steps
|
||||
|
||||
If you're interested in building out this action further, try extending your action to only run on a user's first issue. See our [first-contribution action](https://github.com/actions/first-interaction) for inspiration.
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
# Creating a JavaScript Action
|
||||
|
||||
The [node12-template](https://github.com/actions/node12-template) repo contains everything you need to get started.
|
||||
|
||||
# Create a Repo from the Template
|
||||
|
||||
Navigate to https://github.com/actions/node12-template
|
||||
|
||||
Click on `Use this template` to create the repo for your action.
|
||||
|
||||

|
||||
|
||||
Complete creating your repo and clone the repo.
|
||||
|
||||
> NOTE: The location of the repo will be how users will reference your action in their workflow file with the using keyword.
|
||||
|
||||
e.g. To use https://github.com/actions/setup-node, user's will author:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
using: actions/setup-node@master
|
||||
```
|
||||
|
||||
# Dev Workflow
|
||||
|
||||
The workflow below describes one possible workflow with a branching strategy. Others exist.
|
||||
|
||||
> Key Point: the branch that users reference in their workflow files should reference an action that has **only** the production dependencies.
|
||||
|
||||
The workflow below describes a strategy where you code in master (with node_modules ignored) with a v1 branch users reference and contains the product references. Actions are self contained referenced on the github graph of repos.
|
||||
|
||||
## Install Dependencies
|
||||
|
||||
After creating a repo from the template and cloning it, you will be in master. The command below will install the toolkit, other dependencies and dev dependencies
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
```
|
||||
|
||||
## Define Metadata
|
||||
|
||||
Your action has a name and a description. Update the author.
|
||||
|
||||
Create inputs that your unit of work will need. These will be what workflow authors set with the `with:` keyword.
|
||||
|
||||
```yaml
|
||||
name: 'My new action'
|
||||
description: 'A test action'
|
||||
author: 'GitHub'
|
||||
inputs:
|
||||
myInput:
|
||||
description: 'Input to use'
|
||||
default: 'world'
|
||||
runs:
|
||||
using: 'node12'
|
||||
main: 'lib/main.js'
|
||||
|
||||
```
|
||||
|
||||
Note that the action will be run with node 12 (carried by the runner) and the entry point is specified with `main:`
|
||||
|
||||
## Change Code and Add Tests
|
||||
|
||||
The entry point is in main.ts
|
||||
|
||||
```typescript
|
||||
import * as core from '@actions/core';
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const myInput = core.getInput('myInput');
|
||||
core.debug(`Hello ${myInput}!`);
|
||||
} catch (error) {
|
||||
core.setFailed(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
```
|
||||
|
||||
Modify tests in `__tests__\main.test.ts`. The template uses [jest](https://github.com/facebook/jest).
|
||||
|
||||
## Format the Code
|
||||
|
||||
```bash
|
||||
$ npm run format
|
||||
```
|
||||
|
||||
## Build and Test
|
||||
|
||||
```bash
|
||||
$ npm run build
|
||||
|
||||
> node12-template-action@0.0.0 build /Users/user/Projects/testnode12
|
||||
> tsc
|
||||
|
||||
$ npm test
|
||||
|
||||
> jest
|
||||
|
||||
PASS __tests__/main.test.ts
|
||||
TODO - Add a test suite
|
||||
✓ TODO - Add a test (1ms)
|
||||
|
||||
Test Suites: 1 passed, 1 total
|
||||
...
|
||||
```
|
||||
|
||||
## Commit and Push Changes
|
||||
|
||||
```bash
|
||||
$ git add <whatever only files you added>
|
||||
$ git commit -m "Message"
|
||||
```
|
||||
|
||||
## Publish a v1-release Action
|
||||
|
||||
After changing some files, create a v1-release branch which we will release
|
||||
|
||||
```bash
|
||||
$ git checkout -b v1-release
|
||||
```
|
||||
|
||||
> NOTE: We will provide tooling and an action to automate this soon.
|
||||
|
||||
Checkin production dependencies:
|
||||
1. **Do not ignore node_modules**: Add a `!` in front of the `node_modules` line.
|
||||
2. **Delete node_modules**: rm -Rf node_modules
|
||||
3. **Install production dependencies**: npm install --production
|
||||
4. **Add**: git add node_modules
|
||||
|
||||
|
||||
Simply commit and push your action to publish.
|
||||
|
||||
```bash
|
||||
$ git commit -a -m "publishing v1 of action"
|
||||
$ git push
|
||||
```
|
||||
|
||||
> NOTE: Consider versioning your actions with tags. See [versioning](action-versioning.md)
|
||||
|
||||
## Test End To End
|
||||
|
||||
Once the action has a self contained version in the v1-release branch, you can test it by referencing the latest (and potentially unstable) version in the release branch. If you are fixing an issue that someone else is having with your action, you can have them try it before you officially releasing it as the 'v1' version.
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
using: {org}/{reponame}@v1-release
|
||||
```
|
||||
|
||||
## Release Current Changes as v1
|
||||
|
||||
Once you have tested end to end, push a tag of 'v1' to the commit in the release branch.
|
||||
|
||||
See [action versioning](action-versioning.md) for more details.
|
||||
|
||||
# Users Referencing
|
||||
|
||||
Users can now reference your action in their workflows with
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
using: {org}/{reponame}@v1
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# Problem Matchers
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
badFile.js: line 50, col 11, Error - 'myVar' is defined but never used. (no-unused-vars)
|
||||
```
|
||||
|
||||
We can define a problem matcher in json that detects input in that format:
|
||||
|
||||
```json
|
||||
{
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "eslint-compact",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^(.+):\\sline\\s(\\d+),\\scol\\s(\\d+),\\s(Error|Warning|Info)\\s-\\s(.+)\\s\\((.+)\\)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"column": 3,
|
||||
"severity": 4,
|
||||
"message": 5,
|
||||
"code": 6
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The following fields are available for problem matchers:
|
||||
|
||||
```
|
||||
{
|
||||
owner: an ID field that can be used to remove or replace the problem matcher. **required**
|
||||
severity: indicates the default severity, either 'warning' or 'error' case-insensitive. Defaults to 'error'
|
||||
pattern: [
|
||||
{
|
||||
regexp: the regex pattern that provides the groups to match against **required**
|
||||
file: a group number containing the file name
|
||||
fromPath: a group number containing a filepath used to root the file (e.g. a project file)
|
||||
line: a group number containing the line number
|
||||
column: a group number containing the column information
|
||||
severity: a group number containing either 'warning' or 'error' case-insensitive. Defaults to `error`
|
||||
code: a group number containing the error code
|
||||
message: a group number containing the error message. **required** at least one pattern must set the message
|
||||
loop: whether to loop until a match is not found, only valid on the last pattern of a multipattern matcher
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Multiline Matching
|
||||
Consider the following output:
|
||||
|
||||
```
|
||||
test.js
|
||||
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)
|
||||
```
|
||||
|
||||
The file name is printed once, yet multiple error lines are printed. The `loop` keyword provides a way to discover multiple errors in outputs.
|
||||
|
||||
The eslint-stylish problem matcher defined below catches that output, and creates two annotations from it.
|
||||
|
||||
```
|
||||
{
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "eslint-stylish",
|
||||
"pattern": [
|
||||
{
|
||||
// Matches the 1st line in the output
|
||||
"regexp": "^([^\\s].*)$",
|
||||
"file": 1
|
||||
},
|
||||
{
|
||||
// Matches the 2nd and 3rd line in the output
|
||||
"regexp": "^\\s+(\\d+):(\\d+)\\s+(error|warning|info)\\s+(.*)\\s\\s+(.*)$",
|
||||
// File is carried through from above, so we define the rest of the groups
|
||||
"line": 1,
|
||||
"column": 2,
|
||||
"severity": 3,
|
||||
"message": 4,
|
||||
"code": 5,
|
||||
"loop": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
## Duplicate Problem Matchers
|
||||
|
||||
Registering two problem-matchers with the same owner will result in only the problem matcher registered last running.
|
||||
|
||||
## Examples
|
||||
|
||||
Some of the starter actions are already using problem matchers, for example:
|
||||
- [setup-node](https://github.com/actions/setup-node/tree/main/.github)
|
||||
- [setup-python](https://github.com/actions/setup-python/tree/main/.github)
|
||||
- [setup-go](https://github.com/actions/setup-go/tree/main/.github)
|
||||
- [setup-dotnet](https://github.com/actions/setup-dotnet/tree/main/.github)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Regular expression not matching
|
||||
|
||||
Use ECMAScript regular expression syntax when testing patterns.
|
||||
|
||||
### File property 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.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Proxy Server Support
|
||||
|
||||
Self-hosted runners [can be configured](https://help.github.com/en/actions/hosting-your-own-runners/using-a-proxy-server-with-self-hosted-runners) to run behind a proxy server in enterprises.
|
||||
|
||||
For actions to **just work** behind a proxy server:
|
||||
|
||||
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).
|
||||
@@ -0,0 +1,100 @@
|
||||
# Github Package
|
||||
|
||||
In order to support using actions to interact with GitHub, I propose adding a `github` package to the toolkit.
|
||||
|
||||
Its main purpose will be to provide a hydrated GitHub context/Octokit client with some convenience functions. It is largely pulled from the GitHub utilities provided in https://github.com/JasonEtco/actions-toolkit, though it has been condensed.
|
||||
|
||||
### Spec
|
||||
|
||||
##### interfaces.ts
|
||||
|
||||
```ts
|
||||
/*
|
||||
* Interfaces
|
||||
*/
|
||||
|
||||
export interface PayloadRepository {
|
||||
[key: string]: any
|
||||
full_name?: string
|
||||
name: string
|
||||
owner: {
|
||||
[key: string]: any
|
||||
login: string
|
||||
name?: string
|
||||
}
|
||||
html_url?: string
|
||||
}
|
||||
|
||||
export interface WebhookPayloadWithRepository {
|
||||
[key: string]: any
|
||||
repository?: PayloadRepository
|
||||
issue?: {
|
||||
[key: string]: any
|
||||
number: number
|
||||
html_url?: string
|
||||
body?: string
|
||||
}
|
||||
pull_request?: {
|
||||
[key: string]: any
|
||||
number: number
|
||||
html_url?: string
|
||||
body?: string
|
||||
}
|
||||
sender?: {
|
||||
[key: string]: any
|
||||
type: string
|
||||
}
|
||||
action?: string
|
||||
installation?: {
|
||||
id: number
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### context.ts
|
||||
|
||||
Contains a GitHub context
|
||||
|
||||
```ts
|
||||
export class Context {
|
||||
/**
|
||||
* Webhook payload object that triggered the workflow
|
||||
*/
|
||||
public payload: WebhookPayloadWithRepository
|
||||
|
||||
/**
|
||||
* Name of the event that triggered the workflow
|
||||
*/
|
||||
public event: string
|
||||
public sha: string
|
||||
public ref: string
|
||||
public workflow: string
|
||||
public action: string
|
||||
public actor: string
|
||||
|
||||
/**
|
||||
* Hydrate the context from the environment
|
||||
*/
|
||||
constructor ()
|
||||
|
||||
public get issue ()
|
||||
|
||||
public get repo ()
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
##### github.ts
|
||||
|
||||
Contains a hydrated Octokit client
|
||||
|
||||
```ts
|
||||
export class GithubClient extends Octokit {
|
||||
// For making GraphQL requests
|
||||
public graphql: (query: string, variables?: Variables) => Promise<GraphQlQueryResponse>
|
||||
|
||||
// Calls super and initializes graphql
|
||||
constructor (token: string)
|
||||
}
|
||||
```
|
||||
@@ -6,7 +6,7 @@ In order to support the node-config action, I propose adding the following into
|
||||
|
||||
Holds all the functions necessary for interacting with the runner/environment.
|
||||
|
||||
```
|
||||
```ts
|
||||
// Logging functions
|
||||
export function debug(message: string): void
|
||||
export function warning(message: string): void
|
||||
@@ -66,7 +66,7 @@ export function setFailed(message: string): void
|
||||
|
||||
Holds all the functions necessary for file system manipulation (cli scenarios, not fs replacements):
|
||||
|
||||
```
|
||||
```ts
|
||||
/**
|
||||
* Interface for cp/mv options
|
||||
*/
|
||||
@@ -132,7 +132,7 @@ export function which(tool: string, options?: WhichOptions): Promise<string>
|
||||
|
||||
Holds all the functions necessary for running the tools node-config depends on (aka 7-zip and tar)
|
||||
|
||||
```
|
||||
```ts
|
||||
/**
|
||||
* Interface for exec options
|
||||
*/
|
||||
@@ -155,7 +155,7 @@ export function exec(commandLine: string, args?: string[], options?: IExecOption
|
||||
|
||||
Holds all the functions necessary for downloading and caching node.
|
||||
|
||||
```
|
||||
```ts
|
||||
/**
|
||||
* Download a tool from an url and stream it into a file
|
||||
*
|
||||
@@ -4,7 +4,6 @@ module.exports = {
|
||||
roots: ['<rootDir>/packages'],
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/__tests__/*.test.ts'],
|
||||
testRunner: 'jest-circus/runner',
|
||||
transform: {
|
||||
'^.+\\.ts$': 'ts-jest'
|
||||
},
|
||||
|
||||
Generated
+26283
-6559
File diff suppressed because it is too large
Load Diff
+17
-16
@@ -2,30 +2,31 @@
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"audit-all": "lerna run audit-moderate",
|
||||
"bootstrap": "lerna bootstrap",
|
||||
"build": "lerna run tsc",
|
||||
"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",
|
||||
"test-ci": "jest --testPathIgnorePatterns=\"<rootDir>/packages/exec/__tests__/exec.test.ts\""
|
||||
"test": "jest --testTimeout 10000"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^24.0.11",
|
||||
"@types/node": "^11.13.5",
|
||||
"@types/signale": "^1.2.1",
|
||||
"@typescript-eslint/parser": "^1.9.0",
|
||||
"concurrently": "^4.1.0",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-plugin-github": "^2.0.0",
|
||||
"eslint-plugin-jest": "^22.5.1",
|
||||
"jest": "^24.7.1",
|
||||
"jest-circus": "^24.7.1",
|
||||
"lerna": "^3.13.3",
|
||||
"prettier": "^1.17.0",
|
||||
"ts-jest": "^24.0.2",
|
||||
"typescript": "^3.4.4"
|
||||
"@types/jest": "^27.0.2",
|
||||
"@types/node": "^16.18.1",
|
||||
"@types/signale": "^1.4.1",
|
||||
"@typescript-eslint/parser": "^4.0.0",
|
||||
"concurrently": "^6.1.0",
|
||||
"eslint": "^7.23.0",
|
||||
"eslint-plugin-github": "^4.1.3",
|
||||
"eslint-plugin-jest": "^22.21.0",
|
||||
"flow-bin": "^0.115.0",
|
||||
"jest": "^27.2.5",
|
||||
"lerna": "^5.4.0",
|
||||
"prettier": "^1.19.1",
|
||||
"ts-jest": "^27.0.5",
|
||||
"typescript": "^3.9.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Contributions
|
||||
|
||||
This package is used internally by the v2+ versions of [upload-artifact](https://github.com/actions/upload-artifact) and [download-artifact](https://github.com/actions/download-artifact). This package can also be used by other actions to interact with artifacts. Any changes or updates to this package will propagate updates to these actions so it is important that major changes or updates get properly tested.
|
||||
|
||||
Any issues or feature requests that are related to the artifact actions should be filled in the appropriate repo.
|
||||
|
||||
A limited range of unit tests run as part of each PR when making changes to the artifact packages. For small contributions and fixes, they should be sufficient.
|
||||
|
||||
If making large changes, there are a few scenarios that should be tested.
|
||||
|
||||
- Uploading very large artifacts (large artifacts get compressed using gzip so compression/decompression must be tested)
|
||||
- Uploading artifacts with lots of small files (each file is uploaded with its own HTTP call, timeouts and non-success HTTP responses can be expected so they must be properly handled)
|
||||
- Uploading artifacts using a self-hosted runner (uploads and downloads behave differently due to extra latency)
|
||||
- Downloading a single artifact (large and small, if lots of small files are part of an artifact, timeouts and non-success HTTP responses can be expected)
|
||||
- Downloading all artifacts at once
|
||||
|
||||
Large architectural changes can impact upload/download performance so it is important to separately run extra tests. We request that any large contributions/changes have extra detailed testing so we can verify performance and possible regressions.
|
||||
|
||||
It is not possible to run end-to-end tests for artifacts as part of a PR in this repo because certain env variables such as `ACTIONS_RUNTIME_URL` are only available from the context of an action as opposed to a shell script. These env variables are needed in order to make the necessary API calls.
|
||||
|
||||
# Testing
|
||||
|
||||
Any easy way to test changes is to fork the artifact actions and to use `npm link` to test your changes.
|
||||
|
||||
1. Fork the [upload-artifact](https://github.com/actions/upload-artifact) and [download-artifact](https://github.com/actions/download-artifact) repos
|
||||
2. Clone the forks locally
|
||||
3. With your local changes to the toolkit repo, type `npm link` after ensuring there are no errors when running `tsc`
|
||||
4. In the locally cloned fork, type `npm link @actions/artifact`
|
||||
4. Create a new release for your local fork using `tsc` and `npm run release` (this will create a new `dist/index.js` file using `@vercel/ncc`)
|
||||
5. Commit and push your local changes, you will then be able to test your changes with your forked action
|
||||
@@ -0,0 +1,9 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright 2019 GitHub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,213 @@
|
||||
# `@actions/artifact`
|
||||
|
||||
## 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)
|
||||
|
||||
Relative paths and absolute paths are both allowed. Relative paths are rooted against the current working directory.
|
||||
|
||||
## Upload an Artifact
|
||||
|
||||
Method Name: `uploadArtifact`
|
||||
|
||||
#### 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.
|
||||
@@ -0,0 +1,96 @@
|
||||
# @actions/artifact Releases
|
||||
|
||||
### 0.1.0
|
||||
|
||||
- Initial release
|
||||
|
||||
### 0.2.0
|
||||
|
||||
- Fixes to TCP connections not closing
|
||||
- GZip file compression to speed up downloads
|
||||
- Improved logging and output
|
||||
- Extra documentation
|
||||
|
||||
### 0.3.0
|
||||
|
||||
- Fixes to gzip decompression when downloading artifacts
|
||||
- Support handling 429 response codes
|
||||
- Improved download experience when dealing with empty files
|
||||
- Exponential backoff when retryable status codes are encountered
|
||||
- Clearer error message if storage quota has been reached
|
||||
- Improved logging and output during artifact download
|
||||
|
||||
### 0.3.1
|
||||
|
||||
- Fix to ensure temporary gzip files get correctly deleted during artifact upload
|
||||
- Remove spaces as a forbidden character during upload
|
||||
|
||||
### 0.3.2
|
||||
|
||||
- Fix to ensure readstreams get correctly reset in the event of a retry
|
||||
|
||||
### 0.3.3
|
||||
|
||||
- Increase chunk size during upload from 4MB to 8MB
|
||||
- Improve user-agent strings during API calls to help internally diagnose issues
|
||||
|
||||
### 0.3.5
|
||||
|
||||
- Retry in the event of a 413 response
|
||||
|
||||
### 0.4.0
|
||||
|
||||
- Add option to specify custom retentions on artifacts
|
||||
|
||||
### 0.4.1
|
||||
|
||||
- Update to latest @actions/core version
|
||||
|
||||
### 0.4.2
|
||||
|
||||
- Improved retry-ability when a partial artifact download is encountered
|
||||
|
||||
### 0.5.0
|
||||
|
||||
- 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)
|
||||
@@ -0,0 +1,5 @@
|
||||
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'
|
||||
@@ -0,0 +1,14 @@
|
||||
// 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'
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import CRC64, {CRC64DigestEncoding} from '../src/internal/crc64'
|
||||
|
||||
const fixtures = {
|
||||
data:
|
||||
'🚀 👉😎👉 Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n',
|
||||
expected: {
|
||||
hex: '846CE4ADAD6223ED',
|
||||
base64: '7SNira3kbIQ=',
|
||||
buffer: Buffer.from([0xed, 0x23, 0x62, 0xad, 0xad, 0xe4, 0x6c, 0x84])
|
||||
}
|
||||
}
|
||||
|
||||
function assertEncodings(crc: CRC64): void {
|
||||
const encodings = Object.keys(fixtures.expected) as CRC64DigestEncoding[]
|
||||
for (const encoding of encodings) {
|
||||
expect(crc.digest(encoding)).toEqual(fixtures.expected[encoding])
|
||||
}
|
||||
}
|
||||
|
||||
describe('@actions/artifact/src/internal/crc64', () => {
|
||||
it('CRC64 from string', async () => {
|
||||
const crc = new CRC64()
|
||||
crc.update(fixtures.data)
|
||||
|
||||
assertEncodings(crc)
|
||||
})
|
||||
|
||||
it('CRC64 from buffer', async () => {
|
||||
const crc = new CRC64()
|
||||
const buf = Buffer.from(fixtures.data)
|
||||
crc.update(buf)
|
||||
|
||||
assertEncodings(crc)
|
||||
})
|
||||
|
||||
it('CRC64 from split data', async () => {
|
||||
const crc = new CRC64()
|
||||
const splits = fixtures.data.split('\n').slice(0, -1)
|
||||
for (const split of splits) {
|
||||
crc.update(`${split}\n`)
|
||||
}
|
||||
|
||||
assertEncodings(crc)
|
||||
})
|
||||
|
||||
it('flips 64 bits', async () => {
|
||||
const tests = [
|
||||
[BigInt(0), BigInt('0xffffffffffffffff')],
|
||||
[BigInt('0xffffffffffffffff'), BigInt(0)],
|
||||
[BigInt('0xdeadbeef'), BigInt('0xffffffff21524110')]
|
||||
]
|
||||
|
||||
for (const [input, expected] of tests) {
|
||||
expect(CRC64.flip64Bits(input)).toEqual(expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,552 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,490 @@
|
||||
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 400'
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 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 400`
|
||||
)
|
||||
})
|
||||
|
||||
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, 500, 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 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 = 400
|
||||
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,78 @@
|
||||
import {
|
||||
checkArtifactName,
|
||||
checkArtifactFilePath
|
||||
} from '../src/internal/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(() => {
|
||||
checkArtifactName(invalidName)
|
||||
}).toThrow()
|
||||
}
|
||||
|
||||
const validNames = [
|
||||
'my-normal-artifact',
|
||||
'myNormalArtifact',
|
||||
'm¥ñðrmålÄr†ï£å¢†'
|
||||
]
|
||||
for (const validName of validNames) {
|
||||
expect(() => {
|
||||
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',
|
||||
'some/invalid\rartifact/path',
|
||||
'some/invalid\nartifact/path',
|
||||
'some/invalid\r\nartifact/path',
|
||||
''
|
||||
]
|
||||
for (const invalidName of invalidNames) {
|
||||
expect(() => {
|
||||
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(() => {
|
||||
checkArtifactFilePath(validName)
|
||||
}).not.toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
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 {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<HttpClientResponse> {
|
||||
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<HttpClientResponse> {
|
||||
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([400, 200], {
|
||||
responseCode: 400,
|
||||
errorMessage: 'test failed: Artifact service responded with 400'
|
||||
})
|
||||
})
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
#!/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 [ "$expectedContent" == "_EMPTY_" ] && [ ! -s "$path" ]; then
|
||||
exit 0
|
||||
elif [ "$actualContent" != "$expectedContent" ]; then
|
||||
echo "File contents are not correct, expected $expectedContent, received $actualContent"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,154 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as tmp from 'tmp-promise'
|
||||
import * as path from 'path'
|
||||
import * as io from '../../io/src/io'
|
||||
import {promises as fs} from 'fs'
|
||||
import {createGZipFileOnDisk} from '../src/internal/upload-gzip'
|
||||
|
||||
const root = path.join(__dirname, '_temp', 'upload-gzip')
|
||||
const tempGzFilePath = path.join(root, 'file.gz')
|
||||
const tempGzipFilePath = path.join(root, 'file.gzip')
|
||||
const tempTgzFilePath = path.join(root, 'file.tgz')
|
||||
const tempTazFilePath = path.join(root, 'file.taz')
|
||||
const tempZFilePath = path.join(root, 'file.Z')
|
||||
const tempTaZFilePath = path.join(root, 'file.taZ')
|
||||
const tempBz2FilePath = path.join(root, 'file.bz2')
|
||||
const tempTbzFilePath = path.join(root, 'file.tbz')
|
||||
const tempTbz2FilePath = path.join(root, 'file.tbz2')
|
||||
const tempTz2FilePath = path.join(root, 'file.tz2')
|
||||
const tempLzFilePath = path.join(root, 'file.lz')
|
||||
const tempLzmaFilePath = path.join(root, 'file.lzma')
|
||||
const tempTlzFilePath = path.join(root, 'file.tlz')
|
||||
const tempLzoFilePath = path.join(root, 'file.lzo')
|
||||
const tempXzFilePath = path.join(root, 'file.xz')
|
||||
const tempTxzFilePath = path.join(root, 'file.txz')
|
||||
const tempZstFilePath = path.join(root, 'file.zst')
|
||||
const tempZstdFilePath = path.join(root, 'file.zstd')
|
||||
const tempTzstFilePath = path.join(root, 'file.tzst')
|
||||
const tempZipFilePath = path.join(root, 'file.zip')
|
||||
const temp7zFilePath = path.join(root, 'file.7z')
|
||||
const tempNormalFilePath = path.join(root, 'file.txt')
|
||||
|
||||
jest.mock('../src/internal/config-variables')
|
||||
|
||||
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(() => {})
|
||||
|
||||
// clear temp directory and create files that will be "uploaded"
|
||||
await io.rmRF(root)
|
||||
await fs.mkdir(path.join(root))
|
||||
await fs.writeFile(tempGzFilePath, 'a file with a .gz file extension')
|
||||
await fs.writeFile(tempGzipFilePath, 'a file with a .gzip file extension')
|
||||
await fs.writeFile(tempTgzFilePath, 'a file with a .tgz file extension')
|
||||
await fs.writeFile(tempTazFilePath, 'a file with a .taz file extension')
|
||||
await fs.writeFile(tempZFilePath, 'a file with a .Z file extension')
|
||||
await fs.writeFile(tempTaZFilePath, 'a file with a .taZ file extension')
|
||||
await fs.writeFile(tempBz2FilePath, 'a file with a .bz2 file extension')
|
||||
await fs.writeFile(tempTbzFilePath, 'a file with a .tbz file extension')
|
||||
await fs.writeFile(tempTbz2FilePath, 'a file with a .tbz2 file extension')
|
||||
await fs.writeFile(tempTz2FilePath, 'a file with a .tz2 file extension')
|
||||
await fs.writeFile(tempLzFilePath, 'a file with a .lz file extension')
|
||||
await fs.writeFile(tempLzmaFilePath, 'a file with a .lzma file extension')
|
||||
await fs.writeFile(tempTlzFilePath, 'a file with a .tlz file extension')
|
||||
await fs.writeFile(tempLzoFilePath, 'a file with a .lzo file extension')
|
||||
await fs.writeFile(tempXzFilePath, 'a file with a .xz file extension')
|
||||
await fs.writeFile(tempTxzFilePath, 'a file with a .txz file extension')
|
||||
await fs.writeFile(tempZstFilePath, 'a file with a .zst file extension')
|
||||
await fs.writeFile(tempZstdFilePath, 'a file with a .zstd file extension')
|
||||
await fs.writeFile(tempTzstFilePath, 'a file with a .tzst file extension')
|
||||
await fs.writeFile(tempZipFilePath, 'a file with a .zip file extension')
|
||||
await fs.writeFile(temp7zFilePath, 'a file with a .7z file extension')
|
||||
await fs.writeFile(tempNormalFilePath, 'a file with a .txt file extension')
|
||||
})
|
||||
|
||||
test('Number.MAX_SAFE_INTEGER is returned when an existing compressed file is used', async () => {
|
||||
// create temporary file
|
||||
const tempFile = await tmp.file()
|
||||
|
||||
expect(await createGZipFileOnDisk(tempGzFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempGzipFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempTgzFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempTazFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempZFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempTaZFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempBz2FilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempTbzFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempTbz2FilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempTz2FilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempLzFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempLzmaFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempTlzFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempLzoFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempXzFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempTxzFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempZstFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempZstdFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempTzstFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(tempZipFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(await createGZipFileOnDisk(temp7zFilePath, tempFile.path)).toEqual(
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
expect(
|
||||
await createGZipFileOnDisk(tempNormalFilePath, tempFile.path)
|
||||
).not.toEqual(Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
|
||||
test('gzip file on disk gets successfully created', async () => {
|
||||
// create temporary file
|
||||
const tempFile = await tmp.file()
|
||||
|
||||
const gzipFileSize = await createGZipFileOnDisk(
|
||||
tempNormalFilePath,
|
||||
tempFile.path
|
||||
)
|
||||
const fileStat = await fs.stat(tempNormalFilePath)
|
||||
const totalFileSize = fileStat.size
|
||||
|
||||
// original file and gzip file should not be equal in size
|
||||
expect(gzipFileSize).not.toEqual(totalFileSize)
|
||||
})
|
||||
@@ -0,0 +1,353 @@
|
||||
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'
|
||||
|
||||
const artifactName = 'my-artifact'
|
||||
const root = path.join(__dirname, '_temp', 'upload-specification')
|
||||
const goodItem1Path = path.join(
|
||||
root,
|
||||
'folder-a',
|
||||
'folder-b',
|
||||
'folder-c',
|
||||
'good-item1.txt'
|
||||
)
|
||||
const goodItem2Path = path.join(root, 'folder-d', 'good-item2.txt')
|
||||
const goodItem3Path = path.join(root, 'folder-d', 'good-item3.txt')
|
||||
const goodItem4Path = path.join(root, 'folder-d', 'good-item4.txt')
|
||||
const goodItem5Path = path.join(root, 'good-item5.txt')
|
||||
const badItem1Path = path.join(
|
||||
root,
|
||||
'folder-a',
|
||||
'folder-b',
|
||||
'folder-c',
|
||||
'bad-item1.txt'
|
||||
)
|
||||
const badItem2Path = path.join(root, 'folder-d', 'bad-item2.txt')
|
||||
const badItem3Path = path.join(root, 'folder-f', 'bad-item3.txt')
|
||||
const badItem4Path = path.join(root, 'folder-h', 'folder-i', 'bad-item4.txt')
|
||||
const badItem5Path = path.join(root, 'folder-h', 'folder-i', 'bad-item5.txt')
|
||||
const extraFileInFolderCPath = path.join(
|
||||
root,
|
||||
'folder-a',
|
||||
'folder-b',
|
||||
'folder-c',
|
||||
'extra-file-in-folder-c.txt'
|
||||
)
|
||||
const amazingFileInFolderHPath = path.join(root, 'folder-h', 'amazing-item.txt')
|
||||
|
||||
const artifactFilesToUpload = [
|
||||
goodItem1Path,
|
||||
goodItem2Path,
|
||||
goodItem3Path,
|
||||
goodItem4Path,
|
||||
goodItem5Path,
|
||||
extraFileInFolderCPath,
|
||||
amazingFileInFolderHPath
|
||||
]
|
||||
|
||||
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(() => {})
|
||||
|
||||
// clear temp directory
|
||||
await io.rmRF(root)
|
||||
await fs.mkdir(path.join(root, 'folder-a', 'folder-b', 'folder-c'), {
|
||||
recursive: true
|
||||
})
|
||||
await fs.mkdir(path.join(root, 'folder-a', 'folder-b', 'folder-e'), {
|
||||
recursive: true
|
||||
})
|
||||
await fs.mkdir(path.join(root, 'folder-d'), {
|
||||
recursive: true
|
||||
})
|
||||
await fs.mkdir(path.join(root, 'folder-f'), {
|
||||
recursive: true
|
||||
})
|
||||
await fs.mkdir(path.join(root, 'folder-g'), {
|
||||
recursive: true
|
||||
})
|
||||
await fs.mkdir(path.join(root, 'folder-h', 'folder-i'), {
|
||||
recursive: true
|
||||
})
|
||||
|
||||
await fs.writeFile(goodItem1Path, 'good item1 file')
|
||||
await fs.writeFile(goodItem2Path, 'good item2 file')
|
||||
await fs.writeFile(goodItem3Path, 'good item3 file')
|
||||
await fs.writeFile(goodItem4Path, 'good item4 file')
|
||||
await fs.writeFile(goodItem5Path, 'good item5 file')
|
||||
|
||||
await fs.writeFile(badItem1Path, 'bad item1 file')
|
||||
await fs.writeFile(badItem2Path, 'bad item2 file')
|
||||
await fs.writeFile(badItem3Path, 'bad item3 file')
|
||||
await fs.writeFile(badItem4Path, 'bad item4 file')
|
||||
await fs.writeFile(badItem5Path, 'bad item5 file')
|
||||
|
||||
await fs.writeFile(extraFileInFolderCPath, 'extra file')
|
||||
|
||||
await fs.writeFile(amazingFileInFolderHPath, 'amazing file')
|
||||
/*
|
||||
Directory structure of files that get created:
|
||||
root/
|
||||
folder-a/
|
||||
folder-b/
|
||||
folder-c/
|
||||
good-item1.txt
|
||||
bad-item1.txt
|
||||
extra-file-in-folder-c.txt
|
||||
folder-e/
|
||||
folder-d/
|
||||
good-item2.txt
|
||||
good-item3.txt
|
||||
good-item4.txt
|
||||
bad-item2.txt
|
||||
folder-f/
|
||||
bad-item3.txt
|
||||
folder-g/
|
||||
folder-h/
|
||||
amazing-item.txt
|
||||
folder-i/
|
||||
bad-item4.txt
|
||||
bad-item5.txt
|
||||
good-item5.txt
|
||||
*/
|
||||
})
|
||||
|
||||
it('Upload Specification - Fail non-existent rootDirectory', async () => {
|
||||
const invalidRootDirectory = path.join(
|
||||
__dirname,
|
||||
'_temp',
|
||||
'upload-specification-invalid'
|
||||
)
|
||||
expect(() => {
|
||||
getUploadSpecification(
|
||||
artifactName,
|
||||
invalidRootDirectory,
|
||||
artifactFilesToUpload
|
||||
)
|
||||
}).toThrow(`Provided rootDirectory ${invalidRootDirectory} does not exist`)
|
||||
})
|
||||
|
||||
it('Upload Specification - Fail invalid rootDirectory', async () => {
|
||||
expect(() => {
|
||||
getUploadSpecification(artifactName, goodItem1Path, artifactFilesToUpload)
|
||||
}).toThrow(
|
||||
`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])
|
||||
}).toThrow(`File ${fakeFilePath} does not exist`)
|
||||
})
|
||||
|
||||
it('Upload Specification - Non parent directory', async () => {
|
||||
const folderADirectory = path.join(root, 'folder-a')
|
||||
const artifactFiles = [
|
||||
goodItem1Path,
|
||||
badItem1Path,
|
||||
extraFileInFolderCPath,
|
||||
goodItem5Path
|
||||
]
|
||||
expect(() => {
|
||||
getUploadSpecification(artifactName, folderADirectory, artifactFiles)
|
||||
}).toThrow(
|
||||
`The rootDirectory: ${folderADirectory} is not a parent directory of the file: ${goodItem5Path}`
|
||||
)
|
||||
})
|
||||
|
||||
it('Upload Specification - Success', async () => {
|
||||
const specifications = getUploadSpecification(
|
||||
artifactName,
|
||||
root,
|
||||
artifactFilesToUpload
|
||||
)
|
||||
expect(specifications.length).toEqual(7)
|
||||
|
||||
const absolutePaths = specifications.map(item => item.absoluteFilePath)
|
||||
expect(absolutePaths).toContain(goodItem1Path)
|
||||
expect(absolutePaths).toContain(goodItem2Path)
|
||||
expect(absolutePaths).toContain(goodItem3Path)
|
||||
expect(absolutePaths).toContain(goodItem4Path)
|
||||
expect(absolutePaths).toContain(goodItem5Path)
|
||||
expect(absolutePaths).toContain(extraFileInFolderCPath)
|
||||
expect(absolutePaths).toContain(amazingFileInFolderHPath)
|
||||
|
||||
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'
|
||||
)
|
||||
)
|
||||
} 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-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 {
|
||||
throw new Error(
|
||||
'Invalid specification found. This should never be reached'
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('Upload Specification - Success with extra slash', async () => {
|
||||
const rootWithSlash = `${root}/`
|
||||
const specifications = getUploadSpecification(
|
||||
artifactName,
|
||||
rootWithSlash,
|
||||
artifactFilesToUpload
|
||||
)
|
||||
expect(specifications.length).toEqual(7)
|
||||
|
||||
const absolutePaths = specifications.map(item => item.absoluteFilePath)
|
||||
expect(absolutePaths).toContain(goodItem1Path)
|
||||
expect(absolutePaths).toContain(goodItem2Path)
|
||||
expect(absolutePaths).toContain(goodItem3Path)
|
||||
expect(absolutePaths).toContain(goodItem4Path)
|
||||
expect(absolutePaths).toContain(goodItem5Path)
|
||||
expect(absolutePaths).toContain(extraFileInFolderCPath)
|
||||
expect(absolutePaths).toContain(amazingFileInFolderHPath)
|
||||
|
||||
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'
|
||||
)
|
||||
)
|
||||
} 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-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 {
|
||||
throw new Error(
|
||||
'Invalid specification found. This should never be reached'
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('Upload Specification - Directories should not be 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)
|
||||
expect(absolutePaths).toContain(goodItem1Path)
|
||||
expect(absolutePaths).toContain(goodItem4Path)
|
||||
expect(absolutePaths).toContain(badItem3Path)
|
||||
|
||||
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'
|
||||
)
|
||||
)
|
||||
} 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 {
|
||||
throw new Error(
|
||||
'Invalid specification found. This should never be reached'
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,551 @@
|
||||
import * as http from 'http'
|
||||
import * as io from '../../io/src/io'
|
||||
import * as net from 'net'
|
||||
import * as path from 'path'
|
||||
import {mocked} from 'ts-jest/utils'
|
||||
import {exec, execSync} from 'child_process'
|
||||
import {createGunzip} from 'zlib'
|
||||
import {promisify} from 'util'
|
||||
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)
|
||||
})
|
||||
|
||||
function hasMkfifo(): boolean {
|
||||
try {
|
||||
// make sure we drain the stdout
|
||||
return (
|
||||
process.platform !== 'win32' &&
|
||||
execSync('which mkfifo').toString().length > 0
|
||||
)
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const withMkfifoIt = hasMkfifo() ? it : it.skip
|
||||
withMkfifoIt(
|
||||
'Upload Artifact with content from named pipe - Success',
|
||||
async () => {
|
||||
// create a named pipe 'pipe' with content 'hello pipe'
|
||||
const content = Buffer.from('hello pipe')
|
||||
const pipeFilePath = path.join(root, 'pipe')
|
||||
await promisify(exec)('mkfifo pipe', {cwd: root})
|
||||
// don't want to await here as that would block until read
|
||||
fs.writeFile(pipeFilePath, content)
|
||||
|
||||
const artifactName = 'successful-artifact'
|
||||
const uploadSpecification: UploadSpecification[] = [
|
||||
{
|
||||
absoluteFilePath: pipeFilePath,
|
||||
uploadFilePath: `${artifactName}/pipe`
|
||||
}
|
||||
]
|
||||
|
||||
const uploadUrl = `${getRuntimeUrl()}_apis/resources/Containers/13`
|
||||
const uploadHttpClient = new UploadHttpClient()
|
||||
const uploadResult = await uploadHttpClient.uploadArtifactToFileContainer(
|
||||
uploadUrl,
|
||||
uploadSpecification
|
||||
)
|
||||
|
||||
// accesses the ReadableStream that was passed into sendStream
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const stream = mocked(HttpClient.prototype.sendStream).mock.calls[0][2]
|
||||
expect(stream).not.toBeNull()
|
||||
// decompresses the passed stream
|
||||
const data: Buffer[] = []
|
||||
for await (const chunk of stream.pipe(createGunzip())) {
|
||||
data.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string))
|
||||
}
|
||||
const uploaded = Buffer.concat(data)
|
||||
|
||||
expect(uploadResult.failedItems.length).toEqual(0)
|
||||
expect(uploaded).toEqual(content)
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
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'
|
||||
import {Readable} from 'stream'
|
||||
|
||||
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('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 digest = {
|
||||
crc64: 'bSzITYnW/P8=',
|
||||
md5: 'Xiv1fT9AxLbfadrxk2y3ZvgyN0tPwCWafL/wbi9w8mk='
|
||||
}
|
||||
const headers = utils.getUploadHeaders(
|
||||
contentType,
|
||||
true,
|
||||
true,
|
||||
uncompressedLength,
|
||||
size,
|
||||
range,
|
||||
digest
|
||||
)
|
||||
expect(Object.keys(headers).length).toEqual(10)
|
||||
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)
|
||||
expect(headers['x-actions-results-crc64']).toEqual(digest.crc64)
|
||||
expect(headers['x-actions-results-md5']).toEqual(digest.md5)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it('Creates a digest from a readable stream', async () => {
|
||||
const data = 'lorem ipsum'
|
||||
const stream = Readable.from(data)
|
||||
const digest = await utils.digestForStream(stream)
|
||||
|
||||
expect(digest.crc64).toBe('bSzITYnW/P8=')
|
||||
expect(digest.md5).toBe('gKdR/eV3AoZAxBkADjPrpg==')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
# 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,57 @@
|
||||
# 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
+339
@@ -0,0 +1,339 @@
|
||||
{
|
||||
"name": "@actions/artifact",
|
||||
"version": "1.1.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@actions/artifact",
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.9.1",
|
||||
"@actions/http-client": "^2.0.1",
|
||||
"tmp": "^0.2.1",
|
||||
"tmp-promise": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/tmp": "^0.2.1",
|
||||
"typescript": "^3.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/core": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.9.1.tgz",
|
||||
"integrity": "sha512-5ad+U2YGrmmiw6du20AQW5XuWo7UKN2052FjSV7MX+Wfjf8sCqcsZe62NfgHys4QI4/Y+vQvLKYL8jWtA1ZBTA==",
|
||||
"dependencies": {
|
||||
"@actions/http-client": "^2.0.1",
|
||||
"uuid": "^8.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/http-client": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.0.1.tgz",
|
||||
"integrity": "sha512-PIXiMVtz6VvyaRsGY268qvj57hXQEpsYogYOu2nrQhlf+XCGmZstmuZBbAybUl1nQGnvS1k1eEsQ69ZoD7xlSw==",
|
||||
"dependencies": {
|
||||
"tunnel": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"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
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
|
||||
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
|
||||
"dependencies": {
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/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=",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
|
||||
"integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==",
|
||||
"dependencies": {
|
||||
"rimraf": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp-promise": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz",
|
||||
"integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==",
|
||||
"dependencies": {
|
||||
"tmp": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"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": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz",
|
||||
"integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.9.1.tgz",
|
||||
"integrity": "sha512-5ad+U2YGrmmiw6du20AQW5XuWo7UKN2052FjSV7MX+Wfjf8sCqcsZe62NfgHys4QI4/Y+vQvLKYL8jWtA1ZBTA==",
|
||||
"requires": {
|
||||
"@actions/http-client": "^2.0.1",
|
||||
"uuid": "^8.3.2"
|
||||
}
|
||||
},
|
||||
"@actions/http-client": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.0.1.tgz",
|
||||
"integrity": "sha512-PIXiMVtz6VvyaRsGY268qvj57hXQEpsYogYOu2nrQhlf+XCGmZstmuZBbAybUl1nQGnvS1k1eEsQ69ZoD7xlSw==",
|
||||
"requires": {
|
||||
"tunnel": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"@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
|
||||
},
|
||||
"balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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.2.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
|
||||
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"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": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"requires": {
|
||||
"glob": "^7.1.3"
|
||||
}
|
||||
},
|
||||
"tmp": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
|
||||
"integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==",
|
||||
"requires": {
|
||||
"rimraf": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"tmp-promise": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz",
|
||||
"integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==",
|
||||
"requires": {
|
||||
"tmp": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"tunnel": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
|
||||
"integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="
|
||||
},
|
||||
"typescript": {
|
||||
"version": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz",
|
||||
"integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==",
|
||||
"dev": true
|
||||
},
|
||||
"uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
|
||||
},
|
||||
"wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@actions/artifact",
|
||||
"version": "1.1.1",
|
||||
"preview": true,
|
||||
"description": "Actions artifact lib",
|
||||
"keywords": [
|
||||
"github",
|
||||
"actions",
|
||||
"artifact"
|
||||
],
|
||||
"homepage": "https://github.com/actions/toolkit/tree/main/packages/artifact",
|
||||
"license": "MIT",
|
||||
"main": "lib/artifact-client.js",
|
||||
"types": "lib/artifact-client.d.ts",
|
||||
"directories": {
|
||||
"lib": "lib",
|
||||
"test": "__tests__"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"!.DS_Store"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/actions/toolkit.git",
|
||||
"directory": "packages/artifact"
|
||||
},
|
||||
"scripts": {
|
||||
"audit-moderate": "npm install && npm audit --json --audit-level=moderate > audit.json",
|
||||
"test": "echo \"Error: run tests from root\" && exit 1",
|
||||
"tsc": "tsc"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/actions/toolkit/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.9.1",
|
||||
"@actions/http-client": "^2.0.1",
|
||||
"tmp": "^0.2.1",
|
||||
"tmp-promise": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/tmp": "^0.2.1",
|
||||
"typescript": "^3.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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,51 @@
|
||||
/**
|
||||
* 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'
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
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 {
|
||||
createDirectoriesForArtifact,
|
||||
createEmptyFilesForArtifact
|
||||
} from './utils'
|
||||
import {checkArtifactName} from './path-and-artifact-name-validation'
|
||||
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> {
|
||||
core.info(
|
||||
`Starting artifact upload
|
||||
For more detailed logs during the artifact upload process, enable step-debugging: https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging#enabling-step-debug-logging`
|
||||
)
|
||||
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}`)
|
||||
core.info(
|
||||
`Container for artifact "${name}" successfully created. Starting upload of file(s)`
|
||||
)
|
||||
|
||||
// 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
|
||||
core.info(
|
||||
`File upload process has finished. Finalizing the artifact upload`
|
||||
)
|
||||
await uploadHttpClient.patchArtifactSize(uploadResult.totalSize, name)
|
||||
|
||||
if (uploadResult.failedItems.length > 0) {
|
||||
core.info(
|
||||
`Upload finished. There were ${uploadResult.failedItems.length} items that failed to upload`
|
||||
)
|
||||
} else {
|
||||
core.info(
|
||||
`Artifact has been finalized. All files have been successfully uploaded!`
|
||||
)
|
||||
}
|
||||
|
||||
core.info(
|
||||
`
|
||||
The raw size of all the files that were specified for upload is ${uploadResult.totalSize} bytes
|
||||
The size of all the files that were uploaded is ${uploadResult.uploadSize} bytes. This takes into account any gzip compression used to reduce the upload size, time and storage
|
||||
|
||||
Note: The size of downloaded zips can differ significantly from the reported size. For more information see: https://github.com/actions/upload-artifact#zipped-artifact-downloads \r\n`
|
||||
)
|
||||
|
||||
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 set up 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
|
||||
core.info(
|
||||
`starting download of artifact ${currentArtifactToDownload.name} : ${downloadedArtifacts}/${artifacts.count}`
|
||||
)
|
||||
|
||||
// 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,67 @@
|
||||
// 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']
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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 {
|
||||
/**
|
||||
* The size in bytes of data that was transferred during the upload process to the actions backend service. This takes into account possible
|
||||
* gzip compression to reduce the amount of data that needs to be transferred
|
||||
*/
|
||||
uploadSize: number
|
||||
|
||||
/**
|
||||
* The raw size of the files that were specified for upload
|
||||
*/
|
||||
totalSize: number
|
||||
|
||||
/**
|
||||
* An array of files that failed to upload
|
||||
*/
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* CRC64: cyclic redundancy check, 64-bits
|
||||
*
|
||||
* In order to validate that artifacts are not being corrupted over the wire, this redundancy check allows us to
|
||||
* validate that there was no corruption during transmission. The implementation here is based on Go's hash/crc64 pkg,
|
||||
* but without the slicing-by-8 optimization: https://cs.opensource.google/go/go/+/master:src/hash/crc64/crc64.go
|
||||
*
|
||||
* This implementation uses a pregenerated table based on 0x9A6C9329AC4BC9B5 as the polynomial, the same polynomial that
|
||||
* is used for Azure Storage: https://github.com/Azure/azure-storage-net/blob/cbe605f9faa01bfc3003d75fc5a16b2eaccfe102/Lib/Common/Core/Util/Crc64.cs#L27
|
||||
*/
|
||||
|
||||
// when transpile target is >= ES2020 (after dropping node 12) these can be changed to bigint literals - ts(2737)
|
||||
const PREGEN_POLY_TABLE = [
|
||||
BigInt('0x0000000000000000'),
|
||||
BigInt('0x7F6EF0C830358979'),
|
||||
BigInt('0xFEDDE190606B12F2'),
|
||||
BigInt('0x81B31158505E9B8B'),
|
||||
BigInt('0xC962E5739841B68F'),
|
||||
BigInt('0xB60C15BBA8743FF6'),
|
||||
BigInt('0x37BF04E3F82AA47D'),
|
||||
BigInt('0x48D1F42BC81F2D04'),
|
||||
BigInt('0xA61CECB46814FE75'),
|
||||
BigInt('0xD9721C7C5821770C'),
|
||||
BigInt('0x58C10D24087FEC87'),
|
||||
BigInt('0x27AFFDEC384A65FE'),
|
||||
BigInt('0x6F7E09C7F05548FA'),
|
||||
BigInt('0x1010F90FC060C183'),
|
||||
BigInt('0x91A3E857903E5A08'),
|
||||
BigInt('0xEECD189FA00BD371'),
|
||||
BigInt('0x78E0FF3B88BE6F81'),
|
||||
BigInt('0x078E0FF3B88BE6F8'),
|
||||
BigInt('0x863D1EABE8D57D73'),
|
||||
BigInt('0xF953EE63D8E0F40A'),
|
||||
BigInt('0xB1821A4810FFD90E'),
|
||||
BigInt('0xCEECEA8020CA5077'),
|
||||
BigInt('0x4F5FFBD87094CBFC'),
|
||||
BigInt('0x30310B1040A14285'),
|
||||
BigInt('0xDEFC138FE0AA91F4'),
|
||||
BigInt('0xA192E347D09F188D'),
|
||||
BigInt('0x2021F21F80C18306'),
|
||||
BigInt('0x5F4F02D7B0F40A7F'),
|
||||
BigInt('0x179EF6FC78EB277B'),
|
||||
BigInt('0x68F0063448DEAE02'),
|
||||
BigInt('0xE943176C18803589'),
|
||||
BigInt('0x962DE7A428B5BCF0'),
|
||||
BigInt('0xF1C1FE77117CDF02'),
|
||||
BigInt('0x8EAF0EBF2149567B'),
|
||||
BigInt('0x0F1C1FE77117CDF0'),
|
||||
BigInt('0x7072EF2F41224489'),
|
||||
BigInt('0x38A31B04893D698D'),
|
||||
BigInt('0x47CDEBCCB908E0F4'),
|
||||
BigInt('0xC67EFA94E9567B7F'),
|
||||
BigInt('0xB9100A5CD963F206'),
|
||||
BigInt('0x57DD12C379682177'),
|
||||
BigInt('0x28B3E20B495DA80E'),
|
||||
BigInt('0xA900F35319033385'),
|
||||
BigInt('0xD66E039B2936BAFC'),
|
||||
BigInt('0x9EBFF7B0E12997F8'),
|
||||
BigInt('0xE1D10778D11C1E81'),
|
||||
BigInt('0x606216208142850A'),
|
||||
BigInt('0x1F0CE6E8B1770C73'),
|
||||
BigInt('0x8921014C99C2B083'),
|
||||
BigInt('0xF64FF184A9F739FA'),
|
||||
BigInt('0x77FCE0DCF9A9A271'),
|
||||
BigInt('0x08921014C99C2B08'),
|
||||
BigInt('0x4043E43F0183060C'),
|
||||
BigInt('0x3F2D14F731B68F75'),
|
||||
BigInt('0xBE9E05AF61E814FE'),
|
||||
BigInt('0xC1F0F56751DD9D87'),
|
||||
BigInt('0x2F3DEDF8F1D64EF6'),
|
||||
BigInt('0x50531D30C1E3C78F'),
|
||||
BigInt('0xD1E00C6891BD5C04'),
|
||||
BigInt('0xAE8EFCA0A188D57D'),
|
||||
BigInt('0xE65F088B6997F879'),
|
||||
BigInt('0x9931F84359A27100'),
|
||||
BigInt('0x1882E91B09FCEA8B'),
|
||||
BigInt('0x67EC19D339C963F2'),
|
||||
BigInt('0xD75ADABD7A6E2D6F'),
|
||||
BigInt('0xA8342A754A5BA416'),
|
||||
BigInt('0x29873B2D1A053F9D'),
|
||||
BigInt('0x56E9CBE52A30B6E4'),
|
||||
BigInt('0x1E383FCEE22F9BE0'),
|
||||
BigInt('0x6156CF06D21A1299'),
|
||||
BigInt('0xE0E5DE5E82448912'),
|
||||
BigInt('0x9F8B2E96B271006B'),
|
||||
BigInt('0x71463609127AD31A'),
|
||||
BigInt('0x0E28C6C1224F5A63'),
|
||||
BigInt('0x8F9BD7997211C1E8'),
|
||||
BigInt('0xF0F5275142244891'),
|
||||
BigInt('0xB824D37A8A3B6595'),
|
||||
BigInt('0xC74A23B2BA0EECEC'),
|
||||
BigInt('0x46F932EAEA507767'),
|
||||
BigInt('0x3997C222DA65FE1E'),
|
||||
BigInt('0xAFBA2586F2D042EE'),
|
||||
BigInt('0xD0D4D54EC2E5CB97'),
|
||||
BigInt('0x5167C41692BB501C'),
|
||||
BigInt('0x2E0934DEA28ED965'),
|
||||
BigInt('0x66D8C0F56A91F461'),
|
||||
BigInt('0x19B6303D5AA47D18'),
|
||||
BigInt('0x980521650AFAE693'),
|
||||
BigInt('0xE76BD1AD3ACF6FEA'),
|
||||
BigInt('0x09A6C9329AC4BC9B'),
|
||||
BigInt('0x76C839FAAAF135E2'),
|
||||
BigInt('0xF77B28A2FAAFAE69'),
|
||||
BigInt('0x8815D86ACA9A2710'),
|
||||
BigInt('0xC0C42C4102850A14'),
|
||||
BigInt('0xBFAADC8932B0836D'),
|
||||
BigInt('0x3E19CDD162EE18E6'),
|
||||
BigInt('0x41773D1952DB919F'),
|
||||
BigInt('0x269B24CA6B12F26D'),
|
||||
BigInt('0x59F5D4025B277B14'),
|
||||
BigInt('0xD846C55A0B79E09F'),
|
||||
BigInt('0xA72835923B4C69E6'),
|
||||
BigInt('0xEFF9C1B9F35344E2'),
|
||||
BigInt('0x90973171C366CD9B'),
|
||||
BigInt('0x1124202993385610'),
|
||||
BigInt('0x6E4AD0E1A30DDF69'),
|
||||
BigInt('0x8087C87E03060C18'),
|
||||
BigInt('0xFFE938B633338561'),
|
||||
BigInt('0x7E5A29EE636D1EEA'),
|
||||
BigInt('0x0134D92653589793'),
|
||||
BigInt('0x49E52D0D9B47BA97'),
|
||||
BigInt('0x368BDDC5AB7233EE'),
|
||||
BigInt('0xB738CC9DFB2CA865'),
|
||||
BigInt('0xC8563C55CB19211C'),
|
||||
BigInt('0x5E7BDBF1E3AC9DEC'),
|
||||
BigInt('0x21152B39D3991495'),
|
||||
BigInt('0xA0A63A6183C78F1E'),
|
||||
BigInt('0xDFC8CAA9B3F20667'),
|
||||
BigInt('0x97193E827BED2B63'),
|
||||
BigInt('0xE877CE4A4BD8A21A'),
|
||||
BigInt('0x69C4DF121B863991'),
|
||||
BigInt('0x16AA2FDA2BB3B0E8'),
|
||||
BigInt('0xF86737458BB86399'),
|
||||
BigInt('0x8709C78DBB8DEAE0'),
|
||||
BigInt('0x06BAD6D5EBD3716B'),
|
||||
BigInt('0x79D4261DDBE6F812'),
|
||||
BigInt('0x3105D23613F9D516'),
|
||||
BigInt('0x4E6B22FE23CC5C6F'),
|
||||
BigInt('0xCFD833A67392C7E4'),
|
||||
BigInt('0xB0B6C36E43A74E9D'),
|
||||
BigInt('0x9A6C9329AC4BC9B5'),
|
||||
BigInt('0xE50263E19C7E40CC'),
|
||||
BigInt('0x64B172B9CC20DB47'),
|
||||
BigInt('0x1BDF8271FC15523E'),
|
||||
BigInt('0x530E765A340A7F3A'),
|
||||
BigInt('0x2C608692043FF643'),
|
||||
BigInt('0xADD397CA54616DC8'),
|
||||
BigInt('0xD2BD67026454E4B1'),
|
||||
BigInt('0x3C707F9DC45F37C0'),
|
||||
BigInt('0x431E8F55F46ABEB9'),
|
||||
BigInt('0xC2AD9E0DA4342532'),
|
||||
BigInt('0xBDC36EC59401AC4B'),
|
||||
BigInt('0xF5129AEE5C1E814F'),
|
||||
BigInt('0x8A7C6A266C2B0836'),
|
||||
BigInt('0x0BCF7B7E3C7593BD'),
|
||||
BigInt('0x74A18BB60C401AC4'),
|
||||
BigInt('0xE28C6C1224F5A634'),
|
||||
BigInt('0x9DE29CDA14C02F4D'),
|
||||
BigInt('0x1C518D82449EB4C6'),
|
||||
BigInt('0x633F7D4A74AB3DBF'),
|
||||
BigInt('0x2BEE8961BCB410BB'),
|
||||
BigInt('0x548079A98C8199C2'),
|
||||
BigInt('0xD53368F1DCDF0249'),
|
||||
BigInt('0xAA5D9839ECEA8B30'),
|
||||
BigInt('0x449080A64CE15841'),
|
||||
BigInt('0x3BFE706E7CD4D138'),
|
||||
BigInt('0xBA4D61362C8A4AB3'),
|
||||
BigInt('0xC52391FE1CBFC3CA'),
|
||||
BigInt('0x8DF265D5D4A0EECE'),
|
||||
BigInt('0xF29C951DE49567B7'),
|
||||
BigInt('0x732F8445B4CBFC3C'),
|
||||
BigInt('0x0C41748D84FE7545'),
|
||||
BigInt('0x6BAD6D5EBD3716B7'),
|
||||
BigInt('0x14C39D968D029FCE'),
|
||||
BigInt('0x95708CCEDD5C0445'),
|
||||
BigInt('0xEA1E7C06ED698D3C'),
|
||||
BigInt('0xA2CF882D2576A038'),
|
||||
BigInt('0xDDA178E515432941'),
|
||||
BigInt('0x5C1269BD451DB2CA'),
|
||||
BigInt('0x237C997575283BB3'),
|
||||
BigInt('0xCDB181EAD523E8C2'),
|
||||
BigInt('0xB2DF7122E51661BB'),
|
||||
BigInt('0x336C607AB548FA30'),
|
||||
BigInt('0x4C0290B2857D7349'),
|
||||
BigInt('0x04D364994D625E4D'),
|
||||
BigInt('0x7BBD94517D57D734'),
|
||||
BigInt('0xFA0E85092D094CBF'),
|
||||
BigInt('0x856075C11D3CC5C6'),
|
||||
BigInt('0x134D926535897936'),
|
||||
BigInt('0x6C2362AD05BCF04F'),
|
||||
BigInt('0xED9073F555E26BC4'),
|
||||
BigInt('0x92FE833D65D7E2BD'),
|
||||
BigInt('0xDA2F7716ADC8CFB9'),
|
||||
BigInt('0xA54187DE9DFD46C0'),
|
||||
BigInt('0x24F29686CDA3DD4B'),
|
||||
BigInt('0x5B9C664EFD965432'),
|
||||
BigInt('0xB5517ED15D9D8743'),
|
||||
BigInt('0xCA3F8E196DA80E3A'),
|
||||
BigInt('0x4B8C9F413DF695B1'),
|
||||
BigInt('0x34E26F890DC31CC8'),
|
||||
BigInt('0x7C339BA2C5DC31CC'),
|
||||
BigInt('0x035D6B6AF5E9B8B5'),
|
||||
BigInt('0x82EE7A32A5B7233E'),
|
||||
BigInt('0xFD808AFA9582AA47'),
|
||||
BigInt('0x4D364994D625E4DA'),
|
||||
BigInt('0x3258B95CE6106DA3'),
|
||||
BigInt('0xB3EBA804B64EF628'),
|
||||
BigInt('0xCC8558CC867B7F51'),
|
||||
BigInt('0x8454ACE74E645255'),
|
||||
BigInt('0xFB3A5C2F7E51DB2C'),
|
||||
BigInt('0x7A894D772E0F40A7'),
|
||||
BigInt('0x05E7BDBF1E3AC9DE'),
|
||||
BigInt('0xEB2AA520BE311AAF'),
|
||||
BigInt('0x944455E88E0493D6'),
|
||||
BigInt('0x15F744B0DE5A085D'),
|
||||
BigInt('0x6A99B478EE6F8124'),
|
||||
BigInt('0x224840532670AC20'),
|
||||
BigInt('0x5D26B09B16452559'),
|
||||
BigInt('0xDC95A1C3461BBED2'),
|
||||
BigInt('0xA3FB510B762E37AB'),
|
||||
BigInt('0x35D6B6AF5E9B8B5B'),
|
||||
BigInt('0x4AB846676EAE0222'),
|
||||
BigInt('0xCB0B573F3EF099A9'),
|
||||
BigInt('0xB465A7F70EC510D0'),
|
||||
BigInt('0xFCB453DCC6DA3DD4'),
|
||||
BigInt('0x83DAA314F6EFB4AD'),
|
||||
BigInt('0x0269B24CA6B12F26'),
|
||||
BigInt('0x7D0742849684A65F'),
|
||||
BigInt('0x93CA5A1B368F752E'),
|
||||
BigInt('0xECA4AAD306BAFC57'),
|
||||
BigInt('0x6D17BB8B56E467DC'),
|
||||
BigInt('0x12794B4366D1EEA5'),
|
||||
BigInt('0x5AA8BF68AECEC3A1'),
|
||||
BigInt('0x25C64FA09EFB4AD8'),
|
||||
BigInt('0xA4755EF8CEA5D153'),
|
||||
BigInt('0xDB1BAE30FE90582A'),
|
||||
BigInt('0xBCF7B7E3C7593BD8'),
|
||||
BigInt('0xC399472BF76CB2A1'),
|
||||
BigInt('0x422A5673A732292A'),
|
||||
BigInt('0x3D44A6BB9707A053'),
|
||||
BigInt('0x759552905F188D57'),
|
||||
BigInt('0x0AFBA2586F2D042E'),
|
||||
BigInt('0x8B48B3003F739FA5'),
|
||||
BigInt('0xF42643C80F4616DC'),
|
||||
BigInt('0x1AEB5B57AF4DC5AD'),
|
||||
BigInt('0x6585AB9F9F784CD4'),
|
||||
BigInt('0xE436BAC7CF26D75F'),
|
||||
BigInt('0x9B584A0FFF135E26'),
|
||||
BigInt('0xD389BE24370C7322'),
|
||||
BigInt('0xACE74EEC0739FA5B'),
|
||||
BigInt('0x2D545FB4576761D0'),
|
||||
BigInt('0x523AAF7C6752E8A9'),
|
||||
BigInt('0xC41748D84FE75459'),
|
||||
BigInt('0xBB79B8107FD2DD20'),
|
||||
BigInt('0x3ACAA9482F8C46AB'),
|
||||
BigInt('0x45A459801FB9CFD2'),
|
||||
BigInt('0x0D75ADABD7A6E2D6'),
|
||||
BigInt('0x721B5D63E7936BAF'),
|
||||
BigInt('0xF3A84C3BB7CDF024'),
|
||||
BigInt('0x8CC6BCF387F8795D'),
|
||||
BigInt('0x620BA46C27F3AA2C'),
|
||||
BigInt('0x1D6554A417C62355'),
|
||||
BigInt('0x9CD645FC4798B8DE'),
|
||||
BigInt('0xE3B8B53477AD31A7'),
|
||||
BigInt('0xAB69411FBFB21CA3'),
|
||||
BigInt('0xD407B1D78F8795DA'),
|
||||
BigInt('0x55B4A08FDFD90E51'),
|
||||
BigInt('0x2ADA5047EFEC8728')
|
||||
]
|
||||
|
||||
export type CRC64DigestEncoding = 'hex' | 'base64' | 'buffer'
|
||||
|
||||
class CRC64 {
|
||||
private _crc: bigint
|
||||
|
||||
constructor() {
|
||||
this._crc = BigInt(0)
|
||||
}
|
||||
|
||||
update(data: Buffer | string): void {
|
||||
const buffer = typeof data === 'string' ? Buffer.from(data) : data
|
||||
let crc = CRC64.flip64Bits(this._crc)
|
||||
|
||||
for (const dataByte of buffer) {
|
||||
const crcByte = Number(crc & BigInt(0xff))
|
||||
crc = PREGEN_POLY_TABLE[crcByte ^ dataByte] ^ (crc >> BigInt(8))
|
||||
}
|
||||
|
||||
this._crc = CRC64.flip64Bits(crc)
|
||||
}
|
||||
|
||||
digest(encoding?: CRC64DigestEncoding): string | Buffer {
|
||||
switch (encoding) {
|
||||
case 'hex':
|
||||
return this._crc.toString(16).toUpperCase()
|
||||
case 'base64':
|
||||
return this.toBuffer().toString('base64')
|
||||
default:
|
||||
return this.toBuffer()
|
||||
}
|
||||
}
|
||||
|
||||
private toBuffer(): Buffer {
|
||||
return Buffer.from(
|
||||
[0, 8, 16, 24, 32, 40, 48, 56].map(s =>
|
||||
Number((this._crc >> BigInt(s)) & BigInt(0xff))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
static flip64Bits(n: bigint): bigint {
|
||||
return (BigInt(1) << BigInt(64)) - BigInt(1) - n
|
||||
}
|
||||
}
|
||||
|
||||
export default CRC64
|
||||
@@ -0,0 +1,362 @@
|
||||
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 {HttpClientResponse} from '@actions/http-client'
|
||||
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<HttpClientResponse> => {
|
||||
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 until file is created at downloadpath; node15 and up fs.createWriteStream had not created a file yet
|
||||
await new Promise<void>(resolve => {
|
||||
destinationStream.on('close', resolve)
|
||||
if (destinationStream.writableFinished) {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
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: HttpClientResponse
|
||||
try {
|
||||
response = await makeDownloadRequest()
|
||||
} 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: HttpClientResponse,
|
||||
destinationStream: fs.WriteStream,
|
||||
isGzip: boolean
|
||||
): Promise<void> {
|
||||
await new Promise((resolve, reject) => {
|
||||
if (isGzip) {
|
||||
const gunzip = zlib.createGunzip()
|
||||
response.message
|
||||
.on('error', error => {
|
||||
core.info(
|
||||
`An error occurred while attempting to read the response stream`
|
||||
)
|
||||
gunzip.close()
|
||||
destinationStream.close()
|
||||
reject(error)
|
||||
})
|
||||
.pipe(gunzip)
|
||||
.on('error', error => {
|
||||
core.info(
|
||||
`An error occurred while attempting to decompress the response stream`
|
||||
)
|
||||
destinationStream.close()
|
||||
reject(error)
|
||||
})
|
||||
.pipe(destinationStream)
|
||||
.on('close', () => {
|
||||
resolve()
|
||||
})
|
||||
.on('error', error => {
|
||||
core.info(
|
||||
`An error occurred while writing a downloaded file to ${destinationStream.path}`
|
||||
)
|
||||
reject(error)
|
||||
})
|
||||
} else {
|
||||
response.message
|
||||
.on('error', error => {
|
||||
core.info(
|
||||
`An error occurred while attempting to read the response stream`
|
||||
)
|
||||
destinationStream.close()
|
||||
reject(error)
|
||||
})
|
||||
.pipe(destinationStream)
|
||||
.on('close', () => {
|
||||
resolve()
|
||||
})
|
||||
.on('error', error => {
|
||||
core.info(
|
||||
`An error occurred while writing a downloaded file to ${destinationStream.path}`
|
||||
)
|
||||
reject(error)
|
||||
})
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface DownloadResponse {
|
||||
/**
|
||||
* The name of the artifact that was downloaded
|
||||
*/
|
||||
artifactName: string
|
||||
|
||||
/**
|
||||
* The full Path to where the artifact was downloaded
|
||||
*/
|
||||
downloadPath: string
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {HttpClient} from '@actions/http-client'
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 /']
|
||||
])
|
||||
|
||||
/**
|
||||
* 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 [
|
||||
invalidCharacterKey,
|
||||
errorMessageForCharacter
|
||||
] of invalidArtifactNameCharacters) {
|
||||
if (name.includes(invalidCharacterKey)) {
|
||||
throw new Error(
|
||||
`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!`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 [
|
||||
invalidCharacterKey,
|
||||
errorMessageForCharacter
|
||||
] of invalidArtifactFilePathCharacters) {
|
||||
if (path.includes(invalidCharacterKey)) {
|
||||
throw new Error(
|
||||
`Artifact path 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,79 @@
|
||||
import {HttpClientResponse} from '@actions/http-client'
|
||||
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<HttpClientResponse>,
|
||||
customErrorMessages: Map<number, string>,
|
||||
maxAttempts: number
|
||||
): Promise<HttpClientResponse> {
|
||||
let response: HttpClientResponse | 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(
|
||||
name: string,
|
||||
method: () => Promise<HttpClientResponse>,
|
||||
customErrorMessages: Map<number, string> = new Map(),
|
||||
maxAttempts = getRetryLimit()
|
||||
): Promise<HttpClientResponse> {
|
||||
return await retry(name, method, customErrorMessages, maxAttempts)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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
|
||||
|
||||
constructor(displayFrequencyInMilliseconds: number) {
|
||||
this.totalFileStatus = undefined
|
||||
this.displayFrequencyInMilliseconds = displayFrequencyInMilliseconds
|
||||
}
|
||||
|
||||
setTotalNumberOfFilesToProcess(fileTotal: number): void {
|
||||
this.totalNumberOfFilesToProcess = fileTotal
|
||||
this.processedCount = 0
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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,
|
||||
chunkStartIndex: number,
|
||||
chunkEndIndex: number,
|
||||
totalUploadFileSize: number
|
||||
): void {
|
||||
// display 1 decimal place without any rounding
|
||||
const percentage = this.formatPercentage(chunkEndIndex, totalUploadFileSize)
|
||||
info(
|
||||
`Uploaded ${fileName} (${percentage.slice(
|
||||
0,
|
||||
percentage.indexOf('.') + 2
|
||||
)}%) bytes ${chunkStartIndex}:${chunkEndIndex}`
|
||||
)
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.totalFileStatus) {
|
||||
clearInterval(this.totalFileStatus)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as fs from 'fs'
|
||||
import * as zlib from 'zlib'
|
||||
import {promisify} from 'util'
|
||||
const stat = promisify(fs.stat)
|
||||
|
||||
/**
|
||||
* GZipping certain files that are already compressed will likely not yield further size reductions. Creating large temporary gzip
|
||||
* files then will just waste a lot of time before ultimately being discarded (especially for very large files).
|
||||
* If any of these types of files are encountered then on-disk gzip creation will be skipped and the original file will be uploaded as-is
|
||||
*/
|
||||
const gzipExemptFileExtensions = [
|
||||
'.gz', // GZIP
|
||||
'.gzip', // GZIP
|
||||
'.tgz', // GZIP
|
||||
'.taz', // GZIP
|
||||
'.Z', // COMPRESS
|
||||
'.taZ', // COMPRESS
|
||||
'.bz2', // BZIP2
|
||||
'.tbz', // BZIP2
|
||||
'.tbz2', // BZIP2
|
||||
'.tz2', // BZIP2
|
||||
'.lz', // LZIP
|
||||
'.lzma', // LZMA
|
||||
'.tlz', // LZMA
|
||||
'.lzo', // LZOP
|
||||
'.xz', // XZ
|
||||
'.txz', // XZ
|
||||
'.zst', // ZSTD
|
||||
'.zstd', // ZSTD
|
||||
'.tzst', // ZSTD
|
||||
'.zip', // ZIP
|
||||
'.7z' // 7ZIP
|
||||
]
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
for (const gzipExemptExtension of gzipExemptFileExtensions) {
|
||||
if (originalFilePath.endsWith(gzipExemptExtension)) {
|
||||
// return a really large number so that the original file gets uploaded
|
||||
return Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
}
|
||||
|
||||
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(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
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 {
|
||||
digestForStream,
|
||||
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, HttpClientResponse} from '@actions/http-client'
|
||||
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 fileStat: fs.Stats = await stat(parameters.file)
|
||||
const totalFileSize = fileStat.size
|
||||
const isFIFO = fileStat.isFIFO()
|
||||
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
|
||||
// with named pipes the file size is reported as zero in that case don't read the file in memory
|
||||
if (!isFIFO && totalFileSize < 65536) {
|
||||
core.debug(
|
||||
`${parameters.file} is less than 64k in size. Creating a gzip file in-memory to potentially reduce the upload size`
|
||||
)
|
||||
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
|
||||
core.debug(
|
||||
`The gzip file created for ${parameters.file} did not help with reducing the size of the file. The original file will be uploaded as-is`
|
||||
)
|
||||
openUploadStream = () => fs.createReadStream(parameters.file)
|
||||
isGzip = false
|
||||
uploadFileSize = totalFileSize
|
||||
} else {
|
||||
// create a readable stream using a PassThrough stream that is both readable and writable
|
||||
core.debug(
|
||||
`A gzip file created for ${parameters.file} helped with reducing the size of the original file. The file will be uploaded using gzip.`
|
||||
)
|
||||
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()
|
||||
core.debug(
|
||||
`${parameters.file} is greater than 64k in size. Creating a gzip file on-disk ${tempFile.path} to potentially reduce the upload size`
|
||||
)
|
||||
|
||||
// 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
|
||||
// for named pipes totalFileSize is zero, this assumes compression did help
|
||||
if (!isFIFO && totalFileSize < uploadFileSize) {
|
||||
core.debug(
|
||||
`The gzip file created for ${parameters.file} did not help with reducing the size of the file. The original file will be uploaded as-is`
|
||||
)
|
||||
uploadFileSize = totalFileSize
|
||||
uploadFilePath = parameters.file
|
||||
isGzip = false
|
||||
} else {
|
||||
core.debug(
|
||||
`The gzip file created for ${parameters.file} is smaller than the original file. The file will be uploaded using gzip.`
|
||||
)
|
||||
}
|
||||
|
||||
let abortFileUpload = false
|
||||
// upload only a single chunk at a time
|
||||
while (offset < uploadFileSize) {
|
||||
const chunkSize = Math.min(
|
||||
uploadFileSize - offset,
|
||||
parameters.maxChunkSize
|
||||
)
|
||||
|
||||
const startChunkIndex = offset
|
||||
const endChunkIndex = 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: startChunkIndex,
|
||||
end: endChunkIndex,
|
||||
autoClose: false
|
||||
}),
|
||||
startChunkIndex,
|
||||
endChunkIndex,
|
||||
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
|
||||
} else {
|
||||
// if an individual file is greater than 8MB (1024*1024*8) in size, display extra information about the upload status
|
||||
if (uploadFileSize > 8388608) {
|
||||
this.statusReporter.updateLargeFileStatus(
|
||||
parameters.file,
|
||||
startChunkIndex,
|
||||
endChunkIndex,
|
||||
uploadFileSize
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
core.debug(`deleting temporary gzip file ${tempFile.path}`)
|
||||
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> {
|
||||
// open a new stream and read it to compute the digest
|
||||
const digest = await digestForStream(openStream())
|
||||
|
||||
// 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),
|
||||
digest
|
||||
)
|
||||
|
||||
const uploadChunkRequest = async (): Promise<HttpClientResponse> => {
|
||||
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?: HttpClientResponse
|
||||
): 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: HttpClientResponse
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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[]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import * as fs from 'fs'
|
||||
import {debug} from '@actions/core'
|
||||
import {join, normalize, resolve} from 'path'
|
||||
import {checkArtifactFilePath} from './path-and-artifact-name-validation'
|
||||
|
||||
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[] {
|
||||
// artifact name was checked earlier on, no need to check again
|
||||
const specifications: UploadSpecification[] = []
|
||||
|
||||
if (!fs.existsSync(rootDirectory)) {
|
||||
throw new Error(`Provided rootDirectory ${rootDirectory} does not exist`)
|
||||
}
|
||||
if (!fs.statSync(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.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 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,325 @@
|
||||
import crypto from 'crypto'
|
||||
import {promises as fs} from 'fs'
|
||||
import {IncomingHttpHeaders, OutgoingHttpHeaders} from 'http'
|
||||
import {debug, info, warning} from '@actions/core'
|
||||
import {HttpCodes, HttpClient, HttpClientResponse} from '@actions/http-client'
|
||||
import {BearerCredentialHandler} from '@actions/http-client/lib/auth'
|
||||
import {
|
||||
getRuntimeToken,
|
||||
getRuntimeUrl,
|
||||
getWorkFlowRunId,
|
||||
getRetryMultiplier,
|
||||
getInitialRetryIntervalInMilliseconds
|
||||
} from './config-variables'
|
||||
import CRC64 from './crc64'
|
||||
|
||||
/**
|
||||
* 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.trunc(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.GatewayTimeout,
|
||||
HttpCodes.InternalServerError,
|
||||
HttpCodes.ServiceUnavailable,
|
||||
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
|
||||
): OutgoingHttpHeaders {
|
||||
const requestOptions: OutgoingHttpHeaders = {}
|
||||
|
||||
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,
|
||||
digest?: StreamDigest
|
||||
): OutgoingHttpHeaders {
|
||||
const requestOptions: OutgoingHttpHeaders = {}
|
||||
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
|
||||
}
|
||||
if (digest) {
|
||||
requestOptions['x-actions-results-crc64'] = digest.crc64
|
||||
requestOptions['x-actions-results-md5'] = digest.md5
|
||||
}
|
||||
|
||||
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: HttpClientResponse): 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 ######`
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
export interface StreamDigest {
|
||||
crc64: string
|
||||
md5: string
|
||||
}
|
||||
|
||||
export async function digestForStream(
|
||||
stream: NodeJS.ReadableStream
|
||||
): Promise<StreamDigest> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const crc64 = new CRC64()
|
||||
const md5 = crypto.createHash('md5')
|
||||
stream
|
||||
.on('data', data => {
|
||||
crc64.update(data)
|
||||
md5.update(data)
|
||||
})
|
||||
.on('end', () =>
|
||||
resolve({
|
||||
crc64: crc64.digest('base64') as string,
|
||||
md5: md5.digest('base64')
|
||||
})
|
||||
)
|
||||
.on('error', reject)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "./lib",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": [
|
||||
"./src"
|
||||
]
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright 2019 GitHub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
Vendored
+51
@@ -0,0 +1,51 @@
|
||||
# `@actions/cache`
|
||||
|
||||
> Functions necessary for caching dependencies and build outputs to improve workflow execution time.
|
||||
|
||||
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 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).
|
||||
|
||||
#### 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.
|
||||
|
||||
```js
|
||||
const cache = require('@actions/cache');
|
||||
const paths = [
|
||||
'node_modules',
|
||||
'packages/*/node_modules/'
|
||||
]
|
||||
const key = 'npm-foobar-d5ea0750'
|
||||
const restoreKeys = [
|
||||
'npm-foobar-',
|
||||
'npm-'
|
||||
]
|
||||
const cacheKey = await cache.restoreCache(paths, key, restoreKeys)
|
||||
```
|
||||
|
||||
##### Cache segment restore timeout
|
||||
|
||||
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.
|
||||
|
||||
|
||||
Vendored
+162
@@ -0,0 +1,162 @@
|
||||
# @actions/cache Releases
|
||||
|
||||
### 0.1.0
|
||||
|
||||
- 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)
|
||||
|
||||
### 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`
|
||||
@@ -0,0 +1,5 @@
|
||||
name: 'Set env variables'
|
||||
description: 'Sets certain env variables so that e2e restore and save cache can be tested in a shell'
|
||||
runs:
|
||||
using: 'node12'
|
||||
main: 'index.js'
|
||||
@@ -0,0 +1 @@
|
||||
hello world
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// 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 restore and save cache 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_TOKEN=${process.env.ACTIONS_RUNTIME_TOKEN}${os.EOL}`, {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
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}`, {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
+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)
|
||||
})
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import {downloadCache, getCacheVersion} from '../src/internal/cacheHttpClient'
|
||||
import {CompressionMethod} from '../src/internal/constants'
|
||||
import * as downloadUtils from '../src/internal/downloadUtils'
|
||||
import {DownloadOptions, getDownloadOptions} from '../src/options'
|
||||
|
||||
jest.mock('../src/internal/downloadUtils')
|
||||
|
||||
test('getCacheVersion with one path returns version', async () => {
|
||||
const paths = ['node_modules']
|
||||
const result = getCacheVersion(paths, undefined, true)
|
||||
expect(result).toEqual(
|
||||
'b3e0c6cb5ecf32614eeb2997d905b9c297046d7cbf69062698f25b14b4cb0985'
|
||||
)
|
||||
})
|
||||
|
||||
test('getCacheVersion with multiple paths returns version', async () => {
|
||||
const paths = ['node_modules', 'dist']
|
||||
const result = getCacheVersion(paths, undefined, true)
|
||||
expect(result).toEqual(
|
||||
'165c3053bc646bf0d4fac17b1f5731caca6fe38e0e464715c0c3c6b6318bf436'
|
||||
)
|
||||
})
|
||||
|
||||
test('getCacheVersion with zstd compression returns version', async () => {
|
||||
const paths = ['node_modules']
|
||||
const result = getCacheVersion(paths, CompressionMethod.Zstd, true)
|
||||
|
||||
expect(result).toEqual(
|
||||
'273877e14fd65d270b87a198edbfa2db5a43de567c9a548d2a2505b408befe24'
|
||||
)
|
||||
})
|
||||
|
||||
test('getCacheVersion with gzip compression returns version', async () => {
|
||||
const paths = ['node_modules']
|
||||
const result = getCacheVersion(paths, CompressionMethod.Gzip, true)
|
||||
|
||||
expect(result).toEqual(
|
||||
'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,
|
||||
'downloadCacheHttpClient'
|
||||
)
|
||||
const downloadCacheStorageSDKMock = jest.spyOn(
|
||||
downloadUtils,
|
||||
'downloadCacheStorageSDK'
|
||||
)
|
||||
|
||||
const archiveLocation = 'http://www.actionscache.test/download'
|
||||
const archivePath = '/foo/bar'
|
||||
|
||||
await downloadCache(archiveLocation, archivePath)
|
||||
|
||||
expect(downloadCacheHttpClientMock).toHaveBeenCalledTimes(1)
|
||||
expect(downloadCacheHttpClientMock).toHaveBeenCalledWith(
|
||||
archiveLocation,
|
||||
archivePath
|
||||
)
|
||||
|
||||
expect(downloadCacheStorageSDKMock).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test('downloadCache uses storage SDK for Azure storage URLs', async () => {
|
||||
const downloadCacheHttpClientMock = jest.spyOn(
|
||||
downloadUtils,
|
||||
'downloadCacheHttpClient'
|
||||
)
|
||||
const downloadCacheStorageSDKMock = jest.spyOn(
|
||||
downloadUtils,
|
||||
'downloadCacheStorageSDK'
|
||||
)
|
||||
|
||||
const archiveLocation = 'http://foo.blob.core.windows.net/bar/baz'
|
||||
const archivePath = '/foo/bar'
|
||||
|
||||
await downloadCache(archiveLocation, archivePath)
|
||||
|
||||
expect(downloadCacheStorageSDKMock).toHaveBeenCalledTimes(1)
|
||||
expect(downloadCacheStorageSDKMock).toHaveBeenCalledWith(
|
||||
archiveLocation,
|
||||
archivePath,
|
||||
getDownloadOptions()
|
||||
)
|
||||
|
||||
expect(downloadCacheHttpClientMock).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test('downloadCache passes options to download methods', async () => {
|
||||
const downloadCacheHttpClientMock = jest.spyOn(
|
||||
downloadUtils,
|
||||
'downloadCacheHttpClient'
|
||||
)
|
||||
const downloadCacheStorageSDKMock = jest.spyOn(
|
||||
downloadUtils,
|
||||
'downloadCacheStorageSDK'
|
||||
)
|
||||
|
||||
const archiveLocation = 'http://foo.blob.core.windows.net/bar/baz'
|
||||
const archivePath = '/foo/bar'
|
||||
const options: DownloadOptions = {downloadConcurrency: 4}
|
||||
|
||||
await downloadCache(archiveLocation, archivePath, options)
|
||||
|
||||
expect(downloadCacheStorageSDKMock).toHaveBeenCalledTimes(1)
|
||||
expect(downloadCacheStorageSDKMock).toHaveBeenCalled()
|
||||
expect(downloadCacheStorageSDKMock).toHaveBeenCalledWith(
|
||||
archiveLocation,
|
||||
archivePath,
|
||||
getDownloadOptions(options)
|
||||
)
|
||||
|
||||
expect(downloadCacheHttpClientMock).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test('downloadCache uses http-client when overridden', async () => {
|
||||
const downloadCacheHttpClientMock = jest.spyOn(
|
||||
downloadUtils,
|
||||
'downloadCacheHttpClient'
|
||||
)
|
||||
const downloadCacheStorageSDKMock = jest.spyOn(
|
||||
downloadUtils,
|
||||
'downloadCacheStorageSDK'
|
||||
)
|
||||
|
||||
const archiveLocation = 'http://foo.blob.core.windows.net/bar/baz'
|
||||
const archivePath = '/foo/bar'
|
||||
const options: DownloadOptions = {useAzureSdk: false}
|
||||
|
||||
await downloadCache(archiveLocation, archivePath, options)
|
||||
|
||||
expect(downloadCacheHttpClientMock).toHaveBeenCalledTimes(1)
|
||||
expect(downloadCacheHttpClientMock).toHaveBeenCalledWith(
|
||||
archiveLocation,
|
||||
archivePath
|
||||
)
|
||||
|
||||
expect(downloadCacheStorageSDKMock).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import {promises as fs} from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as cacheUtils from '../src/internal/cacheUtils'
|
||||
|
||||
test('getArchiveFileSizeInBytes returns file size', () => {
|
||||
const filePath = path.join(__dirname, '__fixtures__', 'helloWorld.txt')
|
||||
|
||||
const size = cacheUtils.getArchiveFileSizeInBytes(filePath)
|
||||
|
||||
expect(size).toBe(11)
|
||||
})
|
||||
|
||||
test('unlinkFile unlinks file', async () => {
|
||||
const testDirectory = await fs.mkdtemp('unlinkFileTest')
|
||||
const testFile = path.join(testDirectory, 'test.txt')
|
||||
await fs.writeFile(testFile, 'hello world')
|
||||
|
||||
await expect(fs.stat(testFile)).resolves.not.toThrow()
|
||||
|
||||
await cacheUtils.unlinkFile(testFile)
|
||||
|
||||
// This should throw as testFile should not exist
|
||||
await expect(fs.stat(testFile)).rejects.toThrow()
|
||||
|
||||
await fs.rmdir(testDirectory)
|
||||
})
|
||||
|
||||
test('assertDefined throws if undefined', () => {
|
||||
expect(() => cacheUtils.assertDefined('test', undefined)).toThrowError()
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Validate args
|
||||
prefix="$1"
|
||||
if [ -z "$prefix" ]; then
|
||||
echo "Must supply prefix argument"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
path="$2"
|
||||
if [ -z "$path" ]; then
|
||||
echo "Must supply path argument"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p $path
|
||||
echo "$prefix $GITHUB_RUN_ID" > $path/test-file.txt
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import * as core from '@actions/core'
|
||||
import {DownloadProgress} from '../src/internal/downloadUtils'
|
||||
|
||||
test('download progress tracked correctly', () => {
|
||||
const progress = new DownloadProgress(1000)
|
||||
|
||||
expect(progress.contentLength).toBe(1000)
|
||||
expect(progress.receivedBytes).toBe(0)
|
||||
expect(progress.segmentIndex).toBe(0)
|
||||
expect(progress.segmentOffset).toBe(0)
|
||||
expect(progress.segmentSize).toBe(0)
|
||||
expect(progress.displayedComplete).toBe(false)
|
||||
expect(progress.timeoutHandle).toBeUndefined()
|
||||
expect(progress.getTransferredBytes()).toBe(0)
|
||||
expect(progress.isDone()).toBe(false)
|
||||
|
||||
progress.nextSegment(500)
|
||||
|
||||
expect(progress.contentLength).toBe(1000)
|
||||
expect(progress.receivedBytes).toBe(0)
|
||||
expect(progress.segmentIndex).toBe(1)
|
||||
expect(progress.segmentOffset).toBe(0)
|
||||
expect(progress.segmentSize).toBe(500)
|
||||
expect(progress.displayedComplete).toBe(false)
|
||||
expect(progress.timeoutHandle).toBeUndefined()
|
||||
expect(progress.getTransferredBytes()).toBe(0)
|
||||
expect(progress.isDone()).toBe(false)
|
||||
|
||||
progress.setReceivedBytes(250)
|
||||
|
||||
expect(progress.contentLength).toBe(1000)
|
||||
expect(progress.receivedBytes).toBe(250)
|
||||
expect(progress.segmentIndex).toBe(1)
|
||||
expect(progress.segmentOffset).toBe(0)
|
||||
expect(progress.segmentSize).toBe(500)
|
||||
expect(progress.displayedComplete).toBe(false)
|
||||
expect(progress.timeoutHandle).toBeUndefined()
|
||||
expect(progress.getTransferredBytes()).toBe(250)
|
||||
expect(progress.isDone()).toBe(false)
|
||||
|
||||
progress.setReceivedBytes(500)
|
||||
|
||||
expect(progress.contentLength).toBe(1000)
|
||||
expect(progress.receivedBytes).toBe(500)
|
||||
expect(progress.segmentIndex).toBe(1)
|
||||
expect(progress.segmentOffset).toBe(0)
|
||||
expect(progress.segmentSize).toBe(500)
|
||||
expect(progress.displayedComplete).toBe(false)
|
||||
expect(progress.timeoutHandle).toBeUndefined()
|
||||
expect(progress.getTransferredBytes()).toBe(500)
|
||||
expect(progress.isDone()).toBe(false)
|
||||
|
||||
progress.nextSegment(500)
|
||||
|
||||
expect(progress.contentLength).toBe(1000)
|
||||
expect(progress.receivedBytes).toBe(0)
|
||||
expect(progress.segmentIndex).toBe(2)
|
||||
expect(progress.segmentOffset).toBe(500)
|
||||
expect(progress.segmentSize).toBe(500)
|
||||
expect(progress.displayedComplete).toBe(false)
|
||||
expect(progress.timeoutHandle).toBeUndefined()
|
||||
expect(progress.getTransferredBytes()).toBe(500)
|
||||
expect(progress.isDone()).toBe(false)
|
||||
|
||||
progress.setReceivedBytes(250)
|
||||
|
||||
expect(progress.contentLength).toBe(1000)
|
||||
expect(progress.receivedBytes).toBe(250)
|
||||
expect(progress.segmentIndex).toBe(2)
|
||||
expect(progress.segmentOffset).toBe(500)
|
||||
expect(progress.segmentSize).toBe(500)
|
||||
expect(progress.displayedComplete).toBe(false)
|
||||
expect(progress.timeoutHandle).toBeUndefined()
|
||||
expect(progress.getTransferredBytes()).toBe(750)
|
||||
expect(progress.isDone()).toBe(false)
|
||||
|
||||
progress.setReceivedBytes(500)
|
||||
|
||||
expect(progress.contentLength).toBe(1000)
|
||||
expect(progress.receivedBytes).toBe(500)
|
||||
expect(progress.segmentIndex).toBe(2)
|
||||
expect(progress.segmentOffset).toBe(500)
|
||||
expect(progress.segmentSize).toBe(500)
|
||||
expect(progress.displayedComplete).toBe(false)
|
||||
expect(progress.timeoutHandle).toBeUndefined()
|
||||
expect(progress.getTransferredBytes()).toBe(1000)
|
||||
expect(progress.isDone()).toBe(true)
|
||||
})
|
||||
|
||||
test('display timer works correctly', done => {
|
||||
const progress = new DownloadProgress(1000)
|
||||
|
||||
const infoMock = jest.spyOn(core, 'info')
|
||||
infoMock.mockImplementation(() => {})
|
||||
|
||||
const check = (): void => {
|
||||
expect(infoMock).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('Received 500 of 1000')
|
||||
)
|
||||
}
|
||||
|
||||
// Validate no further updates are displayed after stopping the timer.
|
||||
const test2 = (): void => {
|
||||
check()
|
||||
expect(progress.timeoutHandle).toBeUndefined()
|
||||
done()
|
||||
}
|
||||
|
||||
// Validate the progress is displayed, stop the timer, and call test2.
|
||||
const test1 = (): void => {
|
||||
check()
|
||||
|
||||
progress.stopDisplayTimer()
|
||||
progress.setReceivedBytes(1000)
|
||||
|
||||
setTimeout(() => test2(), 500)
|
||||
}
|
||||
|
||||
// Start the timer, update the received bytes, and call test1.
|
||||
const start = (): void => {
|
||||
progress.startDisplayTimer(10)
|
||||
expect(progress.timeoutHandle).toBeDefined()
|
||||
|
||||
progress.setReceivedBytes(500)
|
||||
|
||||
setTimeout(() => test1(), 500)
|
||||
}
|
||||
|
||||
start()
|
||||
})
|
||||
|
||||
test('display does not print completed line twice', () => {
|
||||
const progress = new DownloadProgress(1000)
|
||||
|
||||
const infoMock = jest.spyOn(core, 'info')
|
||||
infoMock.mockImplementation(() => {})
|
||||
|
||||
progress.display()
|
||||
|
||||
expect(progress.displayedComplete).toBe(false)
|
||||
expect(infoMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
progress.nextSegment(1000)
|
||||
progress.setReceivedBytes(500)
|
||||
progress.display()
|
||||
|
||||
expect(progress.displayedComplete).toBe(false)
|
||||
expect(infoMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
progress.setReceivedBytes(1000)
|
||||
progress.display()
|
||||
|
||||
expect(progress.displayedComplete).toBe(true)
|
||||
expect(infoMock).toHaveBeenCalledTimes(3)
|
||||
|
||||
progress.display()
|
||||
|
||||
expect(progress.displayedComplete).toBe(true)
|
||||
expect(infoMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
DownloadOptions,
|
||||
UploadOptions,
|
||||
getDownloadOptions,
|
||||
getUploadOptions
|
||||
} from '../src/options'
|
||||
|
||||
const useAzureSdk = true
|
||||
const downloadConcurrency = 8
|
||||
const timeoutInMs = 30000
|
||||
const segmentTimeoutInMs = 600000
|
||||
const lookupOnly = false
|
||||
const uploadConcurrency = 4
|
||||
const uploadChunkSize = 32 * 1024 * 1024
|
||||
|
||||
test('getDownloadOptions sets defaults', async () => {
|
||||
const actualOptions = getDownloadOptions()
|
||||
|
||||
expect(actualOptions).toEqual({
|
||||
useAzureSdk,
|
||||
downloadConcurrency,
|
||||
timeoutInMs,
|
||||
segmentTimeoutInMs,
|
||||
lookupOnly
|
||||
})
|
||||
})
|
||||
|
||||
test('getDownloadOptions overrides all settings', async () => {
|
||||
const expectedOptions: DownloadOptions = {
|
||||
useAzureSdk: false,
|
||||
downloadConcurrency: 14,
|
||||
timeoutInMs: 20000,
|
||||
segmentTimeoutInMs: 3600000,
|
||||
lookupOnly: true
|
||||
}
|
||||
|
||||
const actualOptions = getDownloadOptions(expectedOptions)
|
||||
|
||||
expect(actualOptions).toEqual(expectedOptions)
|
||||
})
|
||||
|
||||
test('getUploadOptions sets defaults', async () => {
|
||||
const actualOptions = getUploadOptions()
|
||||
|
||||
expect(actualOptions).toEqual({
|
||||
uploadConcurrency,
|
||||
uploadChunkSize
|
||||
})
|
||||
})
|
||||
|
||||
test('getUploadOptions overrides all settings', async () => {
|
||||
const expectedOptions: UploadOptions = {
|
||||
uploadConcurrency: 2,
|
||||
uploadChunkSize: 16 * 1024 * 1024
|
||||
}
|
||||
|
||||
const actualOptions = getUploadOptions(expectedOptions)
|
||||
|
||||
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)
|
||||
})
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import {retry, retryTypedResponse} from '../src/internal/requestUtils'
|
||||
import {HttpClientError} from '@actions/http-client'
|
||||
import * as requestUtils from '../src/internal/requestUtils'
|
||||
|
||||
interface ITestResponse {
|
||||
statusCode: number
|
||||
result: string | null
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
function TestResponse(
|
||||
action: number | Error,
|
||||
result: string | null = null
|
||||
): ITestResponse {
|
||||
if (action instanceof Error) {
|
||||
return {
|
||||
statusCode: -1,
|
||||
result,
|
||||
error: action
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
statusCode: action,
|
||||
result,
|
||||
error: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponse(
|
||||
response: ITestResponse | undefined
|
||||
): Promise<ITestResponse> {
|
||||
if (!response) {
|
||||
fail('Retry method called too many times')
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw response.error
|
||||
} else {
|
||||
return Promise.resolve(response)
|
||||
}
|
||||
}
|
||||
|
||||
async function testRetryExpectingResult(
|
||||
responses: ITestResponse[],
|
||||
expectedResult: string | null
|
||||
): Promise<void> {
|
||||
responses = responses.reverse() // Reverse responses since we pop from end
|
||||
|
||||
const actualResult = await retry(
|
||||
'test',
|
||||
async () => handleResponse(responses.pop()),
|
||||
(response: ITestResponse) => response.statusCode,
|
||||
2, // maxAttempts
|
||||
0 // delay
|
||||
)
|
||||
|
||||
expect(actualResult.result).toEqual(expectedResult)
|
||||
}
|
||||
|
||||
async function testRetryConvertingErrorToResult(
|
||||
responses: ITestResponse[],
|
||||
expectedStatus: number,
|
||||
expectedResult: string | null
|
||||
): Promise<void> {
|
||||
responses = responses.reverse() // Reverse responses since we pop from end
|
||||
|
||||
const actualResult = await retry(
|
||||
'test',
|
||||
async () => handleResponse(responses.pop()),
|
||||
(response: ITestResponse) => response.statusCode,
|
||||
2, // maxAttempts
|
||||
0, // delay
|
||||
(e: Error) => {
|
||||
if (e instanceof HttpClientError) {
|
||||
return {
|
||||
statusCode: e.statusCode,
|
||||
result: null,
|
||||
error: null
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(actualResult.statusCode).toEqual(expectedStatus)
|
||||
expect(actualResult.result).toEqual(expectedResult)
|
||||
}
|
||||
|
||||
async function testRetryExpectingError(
|
||||
responses: ITestResponse[]
|
||||
): Promise<void> {
|
||||
responses = responses.reverse() // Reverse responses since we pop from end
|
||||
|
||||
expect(
|
||||
retry(
|
||||
'test',
|
||||
async () => handleResponse(responses.pop()),
|
||||
(response: ITestResponse) => response.statusCode,
|
||||
2, // maxAttempts,
|
||||
0 // delay
|
||||
)
|
||||
).rejects.toBeInstanceOf(Error)
|
||||
}
|
||||
|
||||
test('retry works on successful response', async () => {
|
||||
await testRetryExpectingResult([TestResponse(200, 'Ok')], 'Ok')
|
||||
})
|
||||
|
||||
test('retry works after retryable status code', async () => {
|
||||
await testRetryExpectingResult(
|
||||
[TestResponse(503), TestResponse(200, 'Ok')],
|
||||
'Ok'
|
||||
)
|
||||
})
|
||||
|
||||
test('retry fails after exhausting retries', async () => {
|
||||
await testRetryExpectingError([
|
||||
TestResponse(503),
|
||||
TestResponse(503),
|
||||
TestResponse(200, 'Ok')
|
||||
])
|
||||
})
|
||||
|
||||
test('retry fails after non-retryable status code', async () => {
|
||||
await testRetryExpectingError([TestResponse(500), TestResponse(200, 'Ok')])
|
||||
})
|
||||
|
||||
test('retry works after error', async () => {
|
||||
await testRetryExpectingResult(
|
||||
[TestResponse(new Error('Test error')), TestResponse(200, 'Ok')],
|
||||
'Ok'
|
||||
)
|
||||
})
|
||||
|
||||
test('retry returns after client error', async () => {
|
||||
await testRetryExpectingResult(
|
||||
[TestResponse(400), TestResponse(200, 'Ok')],
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('retry converts errors to response object', async () => {
|
||||
await testRetryConvertingErrorToResult(
|
||||
[TestResponse(new HttpClientError('Test error', 409))],
|
||||
409,
|
||||
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'
|
||||
)
|
||||
}
|
||||
})
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as path from 'path'
|
||||
import {restoreCache} from '../src/cache'
|
||||
import * as cacheHttpClient from '../src/internal/cacheHttpClient'
|
||||
import * as cacheUtils from '../src/internal/cacheUtils'
|
||||
import {CacheFilename, CompressionMethod} from '../src/internal/constants'
|
||||
import {ArtifactCacheEntry} from '../src/internal/contracts'
|
||||
import * as tar from '../src/internal/tar'
|
||||
|
||||
jest.mock('../src/internal/cacheHttpClient')
|
||||
jest.mock('../src/internal/cacheUtils')
|
||||
jest.mock('../src/internal/tar')
|
||||
|
||||
beforeAll(() => {
|
||||
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(() => {})
|
||||
|
||||
jest.spyOn(cacheUtils, 'getCacheFileName').mockImplementation(cm => {
|
||||
const actualUtils = jest.requireActual('../src/internal/cacheUtils')
|
||||
return actualUtils.getCacheFileName(cm)
|
||||
})
|
||||
})
|
||||
|
||||
test('restore with no path should fail', async () => {
|
||||
const paths: string[] = []
|
||||
const key = 'node-test'
|
||||
await expect(restoreCache(paths, key)).rejects.toThrowError(
|
||||
`Path Validation Error: At least one directory or file path is required`
|
||||
)
|
||||
})
|
||||
|
||||
test('restore with too many keys should fail', async () => {
|
||||
const paths = ['node_modules']
|
||||
const key = 'node-test'
|
||||
const restoreKeys = [...Array(20).keys()].map(x => x.toString())
|
||||
await expect(restoreCache(paths, key, restoreKeys)).rejects.toThrowError(
|
||||
`Key Validation Error: Keys are limited to a maximum of 10.`
|
||||
)
|
||||
})
|
||||
|
||||
test('restore with large key should fail', async () => {
|
||||
const paths = ['node_modules']
|
||||
const key = 'foo'.repeat(512) // Over the 512 character limit
|
||||
await expect(restoreCache(paths, key)).rejects.toThrowError(
|
||||
`Key Validation Error: ${key} cannot be larger than 512 characters.`
|
||||
)
|
||||
})
|
||||
|
||||
test('restore with invalid key should fail', async () => {
|
||||
const paths = ['node_modules']
|
||||
const key = 'comma,comma'
|
||||
await expect(restoreCache(paths, key)).rejects.toThrowError(
|
||||
`Key Validation Error: ${key} cannot contain commas.`
|
||||
)
|
||||
})
|
||||
|
||||
test('restore with no cache found', async () => {
|
||||
const paths = ['node_modules']
|
||||
const key = 'node-test'
|
||||
|
||||
jest.spyOn(cacheHttpClient, 'getCacheEntry').mockImplementation(async () => {
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const cacheKey = await restoreCache(paths, key)
|
||||
|
||||
expect(cacheKey).toBe(undefined)
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
const cacheKey = await restoreCache(paths, key)
|
||||
expect(cacheKey).toBe(undefined)
|
||||
expect(logWarningMock).toHaveBeenCalledTimes(1)
|
||||
expect(logWarningMock).toHaveBeenCalledWith(
|
||||
'Failed to restore: HTTP Error Occurred'
|
||||
)
|
||||
})
|
||||
|
||||
test('restore with restore keys and no cache found', async () => {
|
||||
const paths = ['node_modules']
|
||||
const key = 'node-test'
|
||||
const restoreKey = 'node-'
|
||||
|
||||
jest.spyOn(cacheHttpClient, 'getCacheEntry').mockImplementation(async () => {
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const cacheKey = await restoreCache(paths, key, [restoreKey])
|
||||
|
||||
expect(cacheKey).toBe(undefined)
|
||||
})
|
||||
|
||||
test('restore with gzip compressed cache found', async () => {
|
||||
const paths = ['node_modules']
|
||||
const key = 'node-test'
|
||||
|
||||
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 tempPath = '/foo/bar'
|
||||
|
||||
const createTempDirectoryMock = jest.spyOn(cacheUtils, 'createTempDirectory')
|
||||
createTempDirectoryMock.mockImplementation(async () => {
|
||||
return Promise.resolve(tempPath)
|
||||
})
|
||||
|
||||
const archivePath = path.join(tempPath, CacheFilename.Gzip)
|
||||
const downloadCacheMock = jest.spyOn(cacheHttpClient, 'downloadCache')
|
||||
|
||||
const fileSize = 142
|
||||
const getArchiveFileSizeInBytesMock = jest
|
||||
.spyOn(cacheUtils, 'getArchiveFileSizeInBytes')
|
||||
.mockReturnValue(fileSize)
|
||||
|
||||
const extractTarMock = jest.spyOn(tar, 'extractTar')
|
||||
const unlinkFileMock = jest.spyOn(cacheUtils, 'unlinkFile')
|
||||
|
||||
const compression = CompressionMethod.Gzip
|
||||
const getCompressionMock = jest
|
||||
.spyOn(cacheUtils, 'getCompressionMethod')
|
||||
.mockReturnValue(Promise.resolve(compression))
|
||||
|
||||
const cacheKey = await restoreCache(paths, key)
|
||||
|
||||
expect(cacheKey).toBe(key)
|
||||
expect(getCacheMock).toHaveBeenCalledWith([key], paths, {
|
||||
compressionMethod: compression,
|
||||
enableCrossOsArchive: false
|
||||
})
|
||||
expect(createTempDirectoryMock).toHaveBeenCalledTimes(1)
|
||||
expect(downloadCacheMock).toHaveBeenCalledWith(
|
||||
cacheEntry.archiveLocation,
|
||||
archivePath,
|
||||
undefined
|
||||
)
|
||||
expect(getArchiveFileSizeInBytesMock).toHaveBeenCalledWith(archivePath)
|
||||
|
||||
expect(extractTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compression)
|
||||
|
||||
expect(unlinkFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(unlinkFileMock).toHaveBeenCalledWith(archivePath)
|
||||
|
||||
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('restore with zstd compressed cache found', async () => {
|
||||
const paths = ['node_modules']
|
||||
const key = 'node-test'
|
||||
|
||||
const infoMock = jest.spyOn(core, 'info')
|
||||
|
||||
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 tempPath = '/foo/bar'
|
||||
|
||||
const createTempDirectoryMock = jest.spyOn(cacheUtils, 'createTempDirectory')
|
||||
createTempDirectoryMock.mockImplementation(async () => {
|
||||
return Promise.resolve(tempPath)
|
||||
})
|
||||
|
||||
const archivePath = path.join(tempPath, CacheFilename.Zstd)
|
||||
const downloadCacheMock = jest.spyOn(cacheHttpClient, 'downloadCache')
|
||||
|
||||
const fileSize = 62915000
|
||||
const getArchiveFileSizeInBytesMock = jest
|
||||
.spyOn(cacheUtils, 'getArchiveFileSizeInBytes')
|
||||
.mockReturnValue(fileSize)
|
||||
|
||||
const extractTarMock = jest.spyOn(tar, 'extractTar')
|
||||
const compression = CompressionMethod.Zstd
|
||||
const getCompressionMock = jest
|
||||
.spyOn(cacheUtils, 'getCompressionMethod')
|
||||
.mockReturnValue(Promise.resolve(compression))
|
||||
|
||||
const cacheKey = await restoreCache(paths, key)
|
||||
|
||||
expect(cacheKey).toBe(key)
|
||||
expect(getCacheMock).toHaveBeenCalledWith([key], paths, {
|
||||
compressionMethod: compression,
|
||||
enableCrossOsArchive: false
|
||||
})
|
||||
expect(createTempDirectoryMock).toHaveBeenCalledTimes(1)
|
||||
expect(downloadCacheMock).toHaveBeenCalledWith(
|
||||
cacheEntry.archiveLocation,
|
||||
archivePath,
|
||||
undefined
|
||||
)
|
||||
expect(getArchiveFileSizeInBytesMock).toHaveBeenCalledWith(archivePath)
|
||||
expect(infoMock).toHaveBeenCalledWith(`Cache Size: ~60 MB (62915000 B)`)
|
||||
|
||||
expect(extractTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compression)
|
||||
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('restore with cache found for restore key', async () => {
|
||||
const paths = ['node_modules']
|
||||
const key = 'node-test'
|
||||
const restoreKey = 'node-'
|
||||
|
||||
const infoMock = jest.spyOn(core, 'info')
|
||||
|
||||
const cacheEntry: ArtifactCacheEntry = {
|
||||
cacheKey: restoreKey,
|
||||
scope: 'refs/heads/main',
|
||||
archiveLocation: 'www.actionscache.test/download'
|
||||
}
|
||||
const getCacheMock = jest.spyOn(cacheHttpClient, 'getCacheEntry')
|
||||
getCacheMock.mockImplementation(async () => {
|
||||
return Promise.resolve(cacheEntry)
|
||||
})
|
||||
const tempPath = '/foo/bar'
|
||||
|
||||
const createTempDirectoryMock = jest.spyOn(cacheUtils, 'createTempDirectory')
|
||||
createTempDirectoryMock.mockImplementation(async () => {
|
||||
return Promise.resolve(tempPath)
|
||||
})
|
||||
|
||||
const archivePath = path.join(tempPath, CacheFilename.Zstd)
|
||||
const downloadCacheMock = jest.spyOn(cacheHttpClient, 'downloadCache')
|
||||
|
||||
const fileSize = 142
|
||||
const getArchiveFileSizeInBytesMock = jest
|
||||
.spyOn(cacheUtils, 'getArchiveFileSizeInBytes')
|
||||
.mockReturnValue(fileSize)
|
||||
|
||||
const extractTarMock = jest.spyOn(tar, 'extractTar')
|
||||
const compression = CompressionMethod.Zstd
|
||||
const getCompressionMock = jest
|
||||
.spyOn(cacheUtils, 'getCompressionMethod')
|
||||
.mockReturnValue(Promise.resolve(compression))
|
||||
|
||||
const cacheKey = await restoreCache(paths, key, [restoreKey])
|
||||
|
||||
expect(cacheKey).toBe(restoreKey)
|
||||
expect(getCacheMock).toHaveBeenCalledWith([key, restoreKey], paths, {
|
||||
compressionMethod: compression,
|
||||
enableCrossOsArchive: false
|
||||
})
|
||||
expect(createTempDirectoryMock).toHaveBeenCalledTimes(1)
|
||||
expect(downloadCacheMock).toHaveBeenCalledWith(
|
||||
cacheEntry.archiveLocation,
|
||||
archivePath,
|
||||
undefined
|
||||
)
|
||||
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)
|
||||
})
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as path from 'path'
|
||||
import {saveCache} from '../src/cache'
|
||||
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')
|
||||
jest.mock('../src/internal/tar')
|
||||
|
||||
beforeAll(() => {
|
||||
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(() => {})
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
test('save with missing input should fail', async () => {
|
||||
const paths: string[] = []
|
||||
const primaryKey = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
|
||||
await expect(saveCache(paths, primaryKey)).rejects.toThrowError(
|
||||
`Path Validation Error: At least one directory or file path is required`
|
||||
)
|
||||
})
|
||||
|
||||
test('save with large cache outputs should fail', 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))
|
||||
|
||||
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'
|
||||
|
||||
expect(createTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(createTarMock).toHaveBeenCalledWith(
|
||||
archiveFolder,
|
||||
cachePaths,
|
||||
compression
|
||||
)
|
||||
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
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 () => {
|
||||
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')
|
||||
const saveCacheMock = jest.spyOn(cacheHttpClient, 'saveCache')
|
||||
const compression = CompressionMethod.Zstd
|
||||
const getCompressionMock = jest
|
||||
.spyOn(cacheUtils, 'getCompressionMethod')
|
||||
.mockReturnValueOnce(Promise.resolve(compression))
|
||||
|
||||
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, {
|
||||
cacheSize: undefined,
|
||||
compressionMethod: compression,
|
||||
enableCrossOsArchive: false
|
||||
})
|
||||
expect(createTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(saveCacheMock).toHaveBeenCalledTimes(0)
|
||||
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const response: TypedResponse<ReserveCacheResponse> = {
|
||||
statusCode: 500,
|
||||
result: {cacheId},
|
||||
headers: {}
|
||||
}
|
||||
return response
|
||||
})
|
||||
|
||||
const createTarMock = jest.spyOn(tar, 'createTar')
|
||||
|
||||
const saveCacheMock = jest
|
||||
.spyOn(cacheHttpClient, 'saveCache')
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('HTTP Error Occurred')
|
||||
})
|
||||
const compression = CompressionMethod.Zstd
|
||||
const getCompressionMock = jest
|
||||
.spyOn(cacheUtils, 'getCompressionMethod')
|
||||
.mockReturnValueOnce(Promise.resolve(compression))
|
||||
|
||||
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], {
|
||||
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 valid inputs uploads a cache', async () => {
|
||||
const filePath = 'node_modules'
|
||||
const primaryKey = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
|
||||
const cachePaths = [path.resolve(filePath)]
|
||||
|
||||
const cacheId = 4
|
||||
const reserveCacheMock = jest
|
||||
.spyOn(cacheHttpClient, 'reserveCache')
|
||||
.mockImplementation(async () => {
|
||||
const response: TypedResponse<ReserveCacheResponse> = {
|
||||
statusCode: 500,
|
||||
result: {cacheId},
|
||||
headers: {}
|
||||
}
|
||||
return response
|
||||
})
|
||||
const createTarMock = jest.spyOn(tar, 'createTar')
|
||||
|
||||
const saveCacheMock = jest.spyOn(cacheHttpClient, 'saveCache')
|
||||
const compression = CompressionMethod.Zstd
|
||||
const getCompressionMock = jest
|
||||
.spyOn(cacheUtils, 'getCompressionMethod')
|
||||
.mockReturnValue(Promise.resolve(compression))
|
||||
|
||||
await saveCache([filePath], primaryKey)
|
||||
|
||||
expect(reserveCacheMock).toHaveBeenCalledTimes(1)
|
||||
expect(reserveCacheMock).toHaveBeenCalledWith(primaryKey, [filePath], {
|
||||
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
+482
@@ -0,0 +1,482 @@
|
||||
import * as exec from '@actions/exec'
|
||||
import * as io from '@actions/io'
|
||||
import * as path from 'path'
|
||||
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
|
||||
import fs = require('fs')
|
||||
|
||||
jest.mock('@actions/exec')
|
||||
jest.mock('@actions/io')
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
const IS_MAC = process.platform === 'darwin'
|
||||
|
||||
const defaultTarPath = IS_MAC ? 'gtar' : 'tar'
|
||||
|
||||
const defaultEnv = {MSYS: 'winsymlinks:nativestrict'}
|
||||
|
||||
function getTempDir(): string {
|
||||
return path.join(__dirname, '_temp', 'tar')
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(io, 'which').mockImplementation(async tool => {
|
||||
return tool
|
||||
})
|
||||
|
||||
process.env['GITHUB_WORKSPACE'] = process.cwd()
|
||||
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())
|
||||
})
|
||||
|
||||
test('zstd extract tar', async () => {
|
||||
const mkdirMock = jest.spyOn(io, 'mkdirP')
|
||||
const execMock = jest.spyOn(exec, 'exec')
|
||||
|
||||
const archivePath = IS_WINDOWS
|
||||
? `${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(
|
||||
[
|
||||
`"${tarPath}"`,
|
||||
'-xf',
|
||||
IS_WINDOWS ? archivePath.replace(/\\/g, '/') : archivePath,
|
||||
'-P',
|
||||
'-C',
|
||||
IS_WINDOWS ? workspace?.replace(/\\/g, '/') : workspace
|
||||
]
|
||||
.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')
|
||||
const archivePath = IS_WINDOWS
|
||||
? `${process.env['windir']}\\fakepath\\cache.tar`
|
||||
: 'cache.tar'
|
||||
const workspace = process.env['GITHUB_WORKSPACE']
|
||||
|
||||
await tar.extractTar(archivePath, CompressionMethod.Gzip)
|
||||
|
||||
expect(mkdirMock).toHaveBeenCalledWith(workspace)
|
||||
const tarPath = IS_WINDOWS ? GnuTarPathOnWindows : defaultTarPath
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
[
|
||||
`"${tarPath}"`,
|
||||
'-xf',
|
||||
IS_WINDOWS ? archivePath.replace(/\\/g, '/') : archivePath,
|
||||
'-P',
|
||||
'-C',
|
||||
IS_WINDOWS ? workspace?.replace(/\\/g, '/') : workspace
|
||||
]
|
||||
.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 with GNUtar in path', async () => {
|
||||
if (IS_WINDOWS) {
|
||||
// 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(execMock).toHaveBeenCalledTimes(1)
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
[
|
||||
`"tar"`,
|
||||
'-xf',
|
||||
archivePath.replace(/\\/g, '/'),
|
||||
'-P',
|
||||
'-C',
|
||||
workspace?.replace(/\\/g, '/'),
|
||||
'--force-local',
|
||||
'-z'
|
||||
].join(' '),
|
||||
undefined,
|
||||
{
|
||||
cwd: undefined,
|
||||
env: expect.objectContaining(defaultEnv)
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('zstd create tar', async () => {
|
||||
const execMock = jest.spyOn(exec, 'exec')
|
||||
|
||||
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 = IS_WINDOWS ? GnuTarPathOnWindows : defaultTarPath
|
||||
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
[
|
||||
`"${tarPath}"`,
|
||||
'--posix',
|
||||
'-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',
|
||||
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,
|
||||
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')
|
||||
|
||||
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.Gzip)
|
||||
|
||||
const tarPath = IS_WINDOWS ? GnuTarPathOnWindows : defaultTarPath
|
||||
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
[
|
||||
`"${tarPath}"`,
|
||||
'--posix',
|
||||
'-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',
|
||||
ManifestFilename
|
||||
]
|
||||
.concat(IS_WINDOWS ? ['--force-local'] : [])
|
||||
.concat(IS_MAC ? ['--delay-directory-restore'] : [])
|
||||
.concat(['-z'])
|
||||
.join(' '),
|
||||
undefined, // args
|
||||
{
|
||||
cwd: archiveFolder,
|
||||
env: expect.objectContaining(defaultEnv)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('zstd list tar', async () => {
|
||||
const execMock = jest.spyOn(exec, 'exec')
|
||||
|
||||
const archivePath = IS_WINDOWS
|
||||
? `${process.env['windir']}\\fakepath\\cache.tar`
|
||||
: 'cache.tar'
|
||||
|
||||
await tar.listTar(archivePath, CompressionMethod.Zstd)
|
||||
|
||||
const tarPath = IS_WINDOWS ? GnuTarPathOnWindows : defaultTarPath
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
[
|
||||
`"${tarPath}"`,
|
||||
'-tf',
|
||||
IS_WINDOWS ? archivePath.replace(/\\/g, '/') : archivePath,
|
||||
'-P'
|
||||
]
|
||||
.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')
|
||||
|
||||
const archivePath = IS_WINDOWS
|
||||
? `${process.env['windir']}\\fakepath\\cache.tar`
|
||||
: 'cache.tar'
|
||||
|
||||
await tar.listTar(archivePath, CompressionMethod.ZstdWithoutLong)
|
||||
|
||||
const tarPath = IS_WINDOWS ? GnuTarPathOnWindows : defaultTarPath
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
[
|
||||
`"${tarPath}"`,
|
||||
'-tf',
|
||||
IS_WINDOWS ? archivePath.replace(/\\/g, '/') : archivePath,
|
||||
'-P'
|
||||
]
|
||||
.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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('gzip list tar', async () => {
|
||||
const execMock = jest.spyOn(exec, 'exec')
|
||||
const archivePath = IS_WINDOWS
|
||||
? `${process.env['windir']}\\fakepath\\cache.tar`
|
||||
: 'cache.tar'
|
||||
|
||||
await tar.listTar(archivePath, CompressionMethod.Gzip)
|
||||
|
||||
const tarPath = IS_WINDOWS ? GnuTarPathOnWindows : defaultTarPath
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
[
|
||||
`"${tarPath}"`,
|
||||
'-tf',
|
||||
IS_WINDOWS ? archivePath.replace(/\\/g, '/') : archivePath,
|
||||
'-P'
|
||||
]
|
||||
.concat(IS_WINDOWS ? ['--force-local'] : [])
|
||||
.concat(IS_MAC ? ['--delay-directory-restore'] : [])
|
||||
.concat(['-z'])
|
||||
.join(' '),
|
||||
undefined,
|
||||
{
|
||||
cwd: undefined,
|
||||
env: expect.objectContaining(defaultEnv)
|
||||
}
|
||||
)
|
||||
})
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Validate args
|
||||
prefix="$1"
|
||||
if [ -z "$prefix" ]; then
|
||||
echo "Must supply prefix argument"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
path="$2"
|
||||
if [ -z "$path" ]; then
|
||||
echo "Must specify path argument"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Sanity check GITHUB_RUN_ID defined
|
||||
if [ -z "$GITHUB_RUN_ID" ]; then
|
||||
echo "GITHUB_RUN_ID not defined"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify file exists
|
||||
file="$path/test-file.txt"
|
||||
echo "Checking for $file"
|
||||
if [ ! -e $file ]; then
|
||||
echo "File does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify file content
|
||||
content="$(cat $file)"
|
||||
echo "File content:\n$content"
|
||||
if [ -z "$(echo $content | grep --fixed-strings "$prefix $GITHUB_RUN_ID")" ]; then
|
||||
echo "Unexpected file content"
|
||||
exit 1
|
||||
fi
|
||||
+1106
File diff suppressed because it is too large
Load Diff
Vendored
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@actions/cache",
|
||||
"version": "3.2.1",
|
||||
"preview": true,
|
||||
"description": "Actions cache lib",
|
||||
"keywords": [
|
||||
"github",
|
||||
"actions",
|
||||
"cache"
|
||||
],
|
||||
"homepage": "https://github.com/actions/toolkit/tree/main/packages/cache",
|
||||
"license": "MIT",
|
||||
"main": "lib/cache.js",
|
||||
"types": "lib/cache.d.ts",
|
||||
"directories": {
|
||||
"lib": "lib",
|
||||
"test": "__tests__"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"!.DS_Store"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/actions/toolkit.git",
|
||||
"directory": "packages/cache"
|
||||
},
|
||||
"scripts": {
|
||||
"audit-moderate": "npm install && npm audit --json --audit-level=moderate > audit.json",
|
||||
"test": "echo \"Error: run tests from root\" && exit 1",
|
||||
"tsc": "tsc"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/actions/toolkit/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.0",
|
||||
"@actions/exec": "^1.0.1",
|
||||
"@actions/glob": "^0.1.0",
|
||||
"@actions/http-client": "^2.0.1",
|
||||
"@actions/io": "^1.0.1",
|
||||
"@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": {
|
||||
"@types/semver": "^6.0.0",
|
||||
"@types/uuid": "^3.4.5",
|
||||
"typescript": "^4.8.0"
|
||||
}
|
||||
}
|
||||
Vendored
+260
@@ -0,0 +1,260 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as path from 'path'
|
||||
import * as utils from './internal/cacheUtils'
|
||||
import * as cacheHttpClient from './internal/cacheHttpClient'
|
||||
import {createTar, extractTar, listTar} from './internal/tar'
|
||||
import {DownloadOptions, UploadOptions} from './options'
|
||||
|
||||
export class ValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ValidationError'
|
||||
Object.setPrototypeOf(this, ValidationError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export class ReserveCacheError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ReserveCacheError'
|
||||
Object.setPrototypeOf(this, ReserveCacheError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
function checkPaths(paths: string[]): void {
|
||||
if (!paths || paths.length === 0) {
|
||||
throw new ValidationError(
|
||||
`Path Validation Error: At least one directory or file path is required`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function checkKey(key: string): void {
|
||||
if (key.length > 512) {
|
||||
throw new ValidationError(
|
||||
`Key Validation Error: ${key} cannot be larger than 512 characters.`
|
||||
)
|
||||
}
|
||||
const regex = /^[^,]*$/
|
||||
if (!regex.test(key)) {
|
||||
throw new ValidationError(
|
||||
`Key Validation Error: ${key} cannot contain commas.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @param paths a list of file paths to restore from the cache
|
||||
* @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,
|
||||
enableCrossOsArchive = false
|
||||
): Promise<string | undefined> {
|
||||
checkPaths(paths)
|
||||
|
||||
restoreKeys = restoreKeys || []
|
||||
const keys = [primaryKey, ...restoreKeys]
|
||||
|
||||
core.debug('Resolved Keys:')
|
||||
core.debug(JSON.stringify(keys))
|
||||
|
||||
if (keys.length > 10) {
|
||||
throw new ValidationError(
|
||||
`Key Validation Error: Keys are limited to a maximum of 10.`
|
||||
)
|
||||
}
|
||||
for (const key of keys) {
|
||||
checkKey(key)
|
||||
}
|
||||
|
||||
const compressionMethod = await utils.getCompressionMethod()
|
||||
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,
|
||||
archivePath,
|
||||
options
|
||||
)
|
||||
|
||||
if (core.isDebug()) {
|
||||
await listTar(archivePath, compressionMethod)
|
||||
}
|
||||
|
||||
const archiveFileSize = utils.getArchiveFileSizeInBytes(archivePath)
|
||||
core.info(
|
||||
`Cache Size: ~${Math.round(
|
||||
archiveFileSize / (1024 * 1024)
|
||||
)} MB (${archiveFileSize} B)`
|
||||
)
|
||||
|
||||
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 {
|
||||
await utils.unlinkFile(archivePath)
|
||||
} catch (error) {
|
||||
core.debug(`Failed to delete archive: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a list of files with the specified key
|
||||
*
|
||||
* @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,
|
||||
enableCrossOsArchive = false
|
||||
): Promise<number> {
|
||||
checkPaths(paths)
|
||||
checkKey(key)
|
||||
|
||||
const compressionMethod = await utils.getCompressionMethod()
|
||||
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,
|
||||
utils.getCacheFileName(compressionMethod)
|
||||
)
|
||||
|
||||
core.debug(`Archive Path: ${archivePath}`)
|
||||
|
||||
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}`)
|
||||
|
||||
// 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
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user