diff --git a/packages/core/package.json b/packages/core/package.json index df9eb6b7..b40509fa 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,7 +6,6 @@ "core", "actions" ], - "author": "Bryan MacFarlane ", "homepage": "https://github.com/actions/toolkit/tree/master/packages/core", "license": "MIT", "main": "lib/core.js", diff --git a/packages/exec/package.json b/packages/exec/package.json index afa85210..67dc4a1b 100644 --- a/packages/exec/package.json +++ b/packages/exec/package.json @@ -6,7 +6,6 @@ "exec", "actions" ], - "author": "Bryan MacFarlane ", "homepage": "https://github.com/actions/toolkit/tree/master/packages/exec", "license": "MIT", "main": "lib/exec.js", diff --git a/packages/io/package.json b/packages/io/package.json index ddf6a996..037e2213 100644 --- a/packages/io/package.json +++ b/packages/io/package.json @@ -6,7 +6,6 @@ "io", "actions" ], - "author": "Danny McCormick ", "homepage": "https://github.com/actions/toolkit/tree/master/packages/io", "license": "MIT", "main": "lib/io.js", diff --git a/packages/tool-cache/README.md b/packages/tool-cache/README.md new file mode 100644 index 00000000..5856b65f --- /dev/null +++ b/packages/tool-cache/README.md @@ -0,0 +1,7 @@ +# `@actions/tool-cache` + +> Functions necessary for downloading and caching tools. + +## Usage + +See [src/tool-cache.ts](src/tool-cache.ts). \ No newline at end of file diff --git a/packages/tool-cache/__tests__/data/test.7z b/packages/tool-cache/__tests__/data/test.7z new file mode 100644 index 00000000..7f385fc7 Binary files /dev/null and b/packages/tool-cache/__tests__/data/test.7z differ diff --git a/packages/tool-cache/__tests__/externals/zip b/packages/tool-cache/__tests__/externals/zip new file mode 100755 index 00000000..1bbc48ef Binary files /dev/null and b/packages/tool-cache/__tests__/externals/zip differ diff --git a/packages/tool-cache/__tests__/tool-cache.test.ts b/packages/tool-cache/__tests__/tool-cache.test.ts new file mode 100644 index 00000000..cc8abc79 --- /dev/null +++ b/packages/tool-cache/__tests__/tool-cache.test.ts @@ -0,0 +1,338 @@ +import * as fs from 'fs' +import * as nock from 'nock' +import * as path from 'path' +import * as io from '@actions/io' +import * as exec from '@actions/exec' + +const cachePath = path.join(__dirname, 'CACHE') +const tempPath = path.join(__dirname, 'TEMP') +// Set temp and tool directories before importing (used to set global state) +process.env['RUNNER_TEMPDIRECTORY'] = tempPath +process.env['RUNNER_TOOLSDIRECTORY'] = cachePath + +// eslint-disable-next-line import/first +import * as tc from '../src/tool-cache' + +const IS_WINDOWS = process.platform === 'win32' + +describe('@actions/tool-cache', function() { + beforeAll(function() { + nock('http://example.com') + .persist() + .get('/bytes/35') + .reply(200, { + username: 'abc', + password: 'def' + }) + }) + + beforeEach(async function() { + await io.rmRF(cachePath) + await io.rmRF(tempPath) + await io.mkdirP(cachePath) + await io.mkdirP(tempPath) + }) + + afterAll(async function() { + await io.rmRF(tempPath) + await io.rmRF(cachePath) + }) + + it('downloads a 35 byte file', async () => { + const downPath: string = await tc.downloadTool( + 'http://example.com/bytes/35' + ) + + expect(fs.existsSync(downPath)).toBeTruthy() + expect(fs.statSync(downPath).size).toBe(35) + }) + + it('downloads a 35 byte file after a redirect', async () => { + nock('http://example.com') + .get('/redirect-to') + .reply(303, undefined, { + location: 'http://example.com/bytes/35' + }) + + const downPath: string = await tc.downloadTool( + 'http://example.com/redirect-to' + ) + + expect(fs.existsSync(downPath)).toBeTruthy() + expect(fs.statSync(downPath).size).toBe(35) + }) + + it('has status code in exception dictionary for HTTP error code responses', async () => { + nock('http://example.com') + .get('/bytes/bad') + .reply(400, { + username: 'bad', + password: 'file' + }) + + expect.assertions(2) + + try { + const errorCodeUrl = 'http://example.com/bytes/bad' + await tc.downloadTool(errorCodeUrl) + } catch (err) { + expect(err.toString()).toContain('Unexpected HTTP response: 400') + expect(err['httpStatusCode']).toBe(400) + } + }) + + it('works with redirect code 302', async function() { + nock('http://example.com') + .get('/redirect-to') + .reply(302, undefined, { + location: 'http://example.com/bytes/35' + }) + + const downPath: string = await tc.downloadTool( + 'http://example.com/redirect-to' + ) + + expect(fs.existsSync(downPath)).toBeTruthy() + expect(fs.statSync(downPath).size).toBe(35) + }) + + it('installs a binary tool and finds it', async () => { + const downPath: string = await tc.downloadTool( + 'http://example.com/bytes/35' + ) + + expect(fs.existsSync(downPath)).toBeTruthy() + + await tc.cacheFile(downPath, 'foo', 'foo', '1.1.0') + + const toolPath: string = tc.find('foo', '1.1.0') + expect(fs.existsSync(toolPath)).toBeTruthy() + expect(fs.existsSync(`${toolPath}.complete`)).toBeTruthy() + + const binaryPath: string = path.join(toolPath, 'foo') + expect(fs.existsSync(binaryPath)).toBeTruthy() + }) + + if (IS_WINDOWS) { + it('installs a 7z and finds it', async () => { + const tempDir = path.join(__dirname, 'test-install-7z') + try { + await io.mkdirP(tempDir) + + // copy the 7z file to the test dir + const _7zFile: string = path.join(tempDir, 'test.7z') + await io.cp(path.join(__dirname, 'data', 'test.7z'), _7zFile) + + // extract/cache + const extPath: string = await tc.extract7z(_7zFile) + await tc.cacheDir(extPath, 'my-7z-contents', '1.1.0') + const toolPath: string = tc.find('my-7z-contents', '1.1.0') + + expect(fs.existsSync(toolPath)).toBeTruthy() + expect(fs.existsSync(`${toolPath}.complete`)).toBeTruthy() + expect(fs.existsSync(path.join(toolPath, 'file.txt'))).toBeTruthy() + expect( + fs.existsSync(path.join(toolPath, 'file-with-ç-character.txt')) + ).toBeTruthy() + expect( + fs.existsSync(path.join(toolPath, 'folder', 'nested-file.txt')) + ).toBeTruthy() + } finally { + await io.rmRF(tempDir) + } + }) + + it('extract 7z using custom 7z tool', async function() { + const tempDir = path.join( + __dirname, + 'test-extract-7z-using-custom-7z-tool' + ) + try { + await io.mkdirP(tempDir) + // create mock7zr.cmd + const mock7zrPath: string = path.join(tempDir, 'mock7zr.cmd') + fs.writeFileSync( + mock7zrPath, + [ + 'echo %* > "%~dp0mock7zr-args.txt"', + `"${path.join( + __dirname, + '..', + 'scripts', + 'externals', + '7zdec.exe' + )}" x %5` + ].join('\r\n') + ) + + // copy the 7z file to the test dir + const _7zFile: string = path.join(tempDir, 'test.7z') + await io.cp(path.join(__dirname, 'data', 'test.7z'), _7zFile) + + // extract + const extPath: string = await tc.extract7z( + _7zFile, + undefined, + mock7zrPath + ) + + expect(fs.existsSync(extPath)).toBeTruthy() + expect( + fs.existsSync(path.join(tempDir, 'mock7zr-args.txt')) + ).toBeTruthy() + expect( + fs + .readFileSync(path.join(tempDir, 'mock7zr-args.txt')) + .toString() + .trim() + ).toBe(`x -bb1 -bd -sccUTF-8 ${_7zFile}`) + expect(fs.existsSync(path.join(extPath, 'file.txt'))).toBeTruthy() + expect( + fs.existsSync(path.join(extPath, 'file-with-ç-character.txt')) + ).toBeTruthy() + expect( + fs.existsSync(path.join(extPath, 'folder', 'nested-file.txt')) + ).toBeTruthy() + } finally { + await io.rmRF(tempDir) + } + }) + } + + it('installs a zip and finds it', async () => { + const tempDir = path.join(__dirname, 'test-install-zip') + try { + await io.mkdirP(tempDir) + + // stage the layout for a zip file: + // file.txt + // folder/nested-file.txt + const stagingDir = path.join(tempDir, 'zip-staging') + await io.mkdirP(path.join(stagingDir, 'folder')) + fs.writeFileSync(path.join(stagingDir, 'file.txt'), '') + fs.writeFileSync(path.join(stagingDir, 'folder', 'nested-file.txt'), '') + + // create the zip + const zipFile = path.join(tempDir, 'test.zip') + await io.rmRF(zipFile) + if (IS_WINDOWS) { + const escapedStagingPath = stagingDir.replace(/'/g, "''") // double-up single quotes + const escapedZipFile = zipFile.replace(/'/g, "''") + const powershellPath = await io.which('powershell', true) + const args = [ + '-NoLogo', + '-Sta', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Unrestricted', + '-Command', + `$ErrorActionPreference = 'Stop' ; Add-Type -AssemblyName System.IO.Compression.FileSystem ; [System.IO.Compression.ZipFile]::CreateFromDirectory('${escapedStagingPath}', '${escapedZipFile}')` + ] + await exec.exec(`"${powershellPath}"`, args) + } else { + const zipPath: string = path.join(__dirname, 'externals', 'zip') + await exec.exec(`"${zipPath}`, [zipFile, '-r', '.'], {cwd: stagingDir}) + } + + const extPath: string = await tc.extractZip(zipFile) + await tc.cacheDir(extPath, 'foo', '1.1.0') + const toolPath: string = tc.find('foo', '1.1.0') + + expect(fs.existsSync(toolPath)).toBeTruthy() + expect(fs.existsSync(`${toolPath}.complete`)).toBeTruthy() + expect(fs.existsSync(path.join(toolPath, 'file.txt'))).toBeTruthy() + expect( + fs.existsSync(path.join(toolPath, 'folder', 'nested-file.txt')) + ).toBeTruthy() + } finally { + await io.rmRF(tempDir) + } + }) + + it('installs a zip and extracts it to specified directory', async function() { + const tempDir = path.join(__dirname, 'test-install-zip') + try { + await io.mkdirP(tempDir) + + // stage the layout for a zip file: + // file.txt + // folder/nested-file.txt + const stagingDir = path.join(tempDir, 'zip-staging') + await io.mkdirP(path.join(stagingDir, 'folder')) + fs.writeFileSync(path.join(stagingDir, 'file.txt'), '') + fs.writeFileSync(path.join(stagingDir, 'folder', 'nested-file.txt'), '') + + // create the zip + const zipFile = path.join(tempDir, 'test.zip') + await io.rmRF(zipFile) + if (IS_WINDOWS) { + const escapedStagingPath = stagingDir.replace(/'/g, "''") // double-up single quotes + const escapedZipFile = zipFile.replace(/'/g, "''") + const powershellPath = await io.which('powershell', true) + const args = [ + '-NoLogo', + '-Sta', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Unrestricted', + '-Command', + `$ErrorActionPreference = 'Stop' ; Add-Type -AssemblyName System.IO.Compression.FileSystem ; [System.IO.Compression.ZipFile]::CreateFromDirectory('${escapedStagingPath}', '${escapedZipFile}')` + ] + await exec.exec(`"${powershellPath}"`, args) + } else { + const zipPath = path.join(__dirname, 'externals', 'zip') + await exec.exec(zipPath, [zipFile, '-r', '.'], {cwd: stagingDir}) + } + + const destDir = path.join(__dirname, 'unzip-dest') + await io.rmRF(destDir) + await io.mkdirP(destDir) + try { + const extPath: string = await tc.extractZip(zipFile, destDir) + await tc.cacheDir(extPath, 'foo', '1.1.0') + const toolPath: string = tc.find('foo', '1.1.0') + expect(fs.existsSync(toolPath)).toBeTruthy() + expect(fs.existsSync(`${toolPath}.complete`)).toBeTruthy() + expect(fs.existsSync(path.join(toolPath, 'file.txt'))).toBeTruthy() + expect( + fs.existsSync(path.join(toolPath, 'folder', 'nested-file.txt')) + ).toBeTruthy() + } finally { + await io.rmRF(destDir) + } + } finally { + await io.rmRF(tempDir) + } + }) + + it('works with a 502 temporary failure', async function() { + nock('http://example.com') + .get('/temp502') + .twice() + .reply(502, undefined) + nock('http://example.com') + .get('/temp502') + .reply(200, undefined) + + const statusCodeUrl = 'http://example.com/temp502' + await tc.downloadTool(statusCodeUrl) + }) + + it("doesn't retry 502s more than 3 times", async function() { + nock('http://example.com') + .get('/perm502') + .times(3) + .reply(502, undefined) + + expect.assertions(1) + + try { + const statusCodeUrl = 'http://example.com/perm502' + await tc.downloadTool(statusCodeUrl) + } catch (err) { + expect(err.toString()).toContain('502') + } + }) +}) diff --git a/packages/tool-cache/package-lock.json b/packages/tool-cache/package-lock.json new file mode 100644 index 00000000..7ce81662 --- /dev/null +++ b/packages/tool-cache/package-lock.json @@ -0,0 +1,205 @@ +{ + "name": "@actions/tool-cache", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/nock": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@types/nock/-/nock-10.0.3.tgz", + "integrity": "sha512-OthuN+2FuzfZO3yONJ/QVjKmLEuRagS9TV9lEId+WHL9KhftYG+/2z+pxlr0UgVVXSpVD8woie/3fzQn8ft/Ow==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/node": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.0.2.tgz", + "integrity": "sha512-5tabW/i+9mhrfEOUcLDu2xBPsHJ+X5Orqy9FKpale3SjDA17j5AEpYq5vfy3oAeAHGcvANRCO3NV3d2D6q3NiA==", + "dev": true + }, + "@types/semver": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-6.0.0.tgz", + "integrity": "sha512-OO0srjOGH99a4LUN2its3+r6CBYcplhJ466yLqs+zvAWgphCpS8hYZEZ797tRDP/QKcqTdb/YCN6ifASoAWkrQ==", + "dev": true + }, + "@types/uuid": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-3.4.4.tgz", + "integrity": "sha512-tPIgT0GUmdJQNSHxp0X2jnpQfBSTfGxUMc/2CXBU2mnyTFVYVa2ojpoQ74w0U2yn2vw3jnC640+77lkFFpdVDw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, + "chai": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.2.0.tgz", + "integrity": "sha512-XQU3bhBukrOsQCuwZndwGcCVQHyZi53fQ6Ys1Fym7E4olpIqqZZhhoFJoaKVvV17lWQoXYwgWN2nF5crA8J2jw==", + "dev": true, + "requires": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.2", + "deep-eql": "^3.0.1", + "get-func-name": "^2.0.0", + "pathval": "^1.1.0", + "type-detect": "^4.0.5" + } + }, + "check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "requires": { + "type-detect": "^4.0.0" + } + }, + "deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=", + "dev": true + }, + "get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "dev": true + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", + "dev": true + }, + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", + "dev": true + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + }, + "nock": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/nock/-/nock-10.0.6.tgz", + "integrity": "sha512-b47OWj1qf/LqSQYnmokNWM8D88KvUl2y7jT0567NB3ZBAZFz2bWp2PC81Xn7u8F2/vJxzkzNZybnemeFa7AZ2w==", + "dev": true, + "requires": { + "chai": "^4.1.2", + "debug": "^4.1.0", + "deep-equal": "^1.0.0", + "json-stringify-safe": "^5.0.1", + "lodash": "^4.17.5", + "mkdirp": "^0.5.0", + "propagate": "^1.0.0", + "qs": "^6.5.1", + "semver": "^5.5.0" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "pathval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", + "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", + "dev": true + }, + "propagate": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-1.0.0.tgz", + "integrity": "sha1-AMLa7t2iDofjeCs0Stuhzd1q1wk=", + "dev": true + }, + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==", + "dev": true + }, + "semver": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.1.0.tgz", + "integrity": "sha512-kCqEOOHoBcFs/2Ccuk4Xarm/KiWRSLEX9CAZF8xkJ6ZPlIoTZ8V5f7J16vYLJqDbR7KrxTJpR2lqjIEm2Qx9cQ==" + }, + "tunnel": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.4.tgz", + "integrity": "sha1-LTeFoVjBdMmhbcLARuxfxfF0IhM=" + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, + "typed-rest-client": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.4.0.tgz", + "integrity": "sha512-f+3+X13CIpkv0WvFERkXq4aH5BYzyeYclf8t+X7oa/YaE80EjYW12kphY0aEQBaL9RzChP0MSbsVhB4X+bzyDw==", + "requires": { + "tunnel": "0.0.4", + "underscore": "1.8.3" + } + }, + "underscore": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz", + "integrity": "sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI=" + }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + } + } +} diff --git a/packages/tool-cache/package.json b/packages/tool-cache/package.json new file mode 100644 index 00000000..967fa063 --- /dev/null +++ b/packages/tool-cache/package.json @@ -0,0 +1,48 @@ +{ + "name": "@actions/tool-cache", + "version": "1.0.0", + "description": "Actions tool-cache lib", + "keywords": [ + "exec", + "actions" + ], + "homepage": "https://github.com/actions/toolkit/tree/master/packages/exec", + "license": "MIT", + "main": "lib/tool-cache.js", + "directories": { + "lib": "lib", + "test": "__tests__" + }, + "files": [ + "lib", + "scripts" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/actions/toolkit.git" + }, + "scripts": { + "test": "echo \"Error: run tests from root\" && exit 1", + "tsc": "tsc" + }, + "bugs": { + "url": "https://github.com/actions/toolkit/issues" + }, + "dependencies": { + "semver": "^6.1.0", + "typed-rest-client": "^1.4.0", + "uuid": "^3.3.2", + "@actions/core": "^0.1.0", + "@actions/io": "^1.0.0", + "@actions/exec": "^1.0.0" + }, + "devDependencies": { + "nock": "^10.0.6", + "@types/nock": "^10.0.3", + "@types/semver": "^6.0.0", + "@types/uuid": "^3.4.4" + } +} diff --git a/packages/tool-cache/scripts/Invoke-7zdec.ps1 b/packages/tool-cache/scripts/Invoke-7zdec.ps1 new file mode 100644 index 00000000..8b39bb4d --- /dev/null +++ b/packages/tool-cache/scripts/Invoke-7zdec.ps1 @@ -0,0 +1,60 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$Source, + + [Parameter(Mandatory = $true)] + [string]$Target) + +# This script translates the output from 7zdec into UTF8. Node has limited +# built-in support for encodings. +# +# 7zdec uses the system default code page. The system default code page varies +# depending on the locale configuration. On an en-US box, the system default code +# page is Windows-1252. +# +# Note, on a typical en-US box, testing with the 'ç' character is a good way to +# determine whether data is passed correctly between processes. This is because +# the 'ç' character has a different code point across each of the common encodings +# on a typical en-US box, i.e. +# 1) the default console-output code page (IBM437) +# 2) the system default code page (i.e. CP_ACP) (Windows-1252) +# 3) UTF8 + +$ErrorActionPreference = 'Stop' + +# Redefine the wrapper over STDOUT to use UTF8. Node expects UTF8 by default. +$stdout = [System.Console]::OpenStandardOutput() +$utf8 = New-Object System.Text.UTF8Encoding($false) # do not emit BOM +$writer = New-Object System.IO.StreamWriter($stdout, $utf8) +[System.Console]::SetOut($writer) + +# All subsequent output must be written using [System.Console]::WriteLine(). In +# PowerShell 4, Write-Host and Out-Default do not consider the updated stream writer. + +Set-Location -LiteralPath $Target + +# Print the ##command. +$_7zdec = Join-Path -Path "$PSScriptRoot" -ChildPath "externals/7zdec.exe" +[System.Console]::WriteLine("##[command]$_7zdec x `"$Source`"") + +# The $OutputEncoding variable instructs PowerShell how to interpret the output +# from the external command. +$OutputEncoding = [System.Text.Encoding]::Default + +# Note, the output from 7zdec.exe needs to be iterated over. Otherwise PowerShell.exe +# will launch the external command in such a way that it inherits the streams. +& $_7zdec x $Source 2>&1 | + ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + [System.Console]::WriteLine($_.Exception.Message) + } + else { + [System.Console]::WriteLine($_) + } + } +[System.Console]::WriteLine("##[debug]7zdec.exe exit code '$LASTEXITCODE'") +[System.Console]::Out.Flush() +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} \ No newline at end of file diff --git a/packages/tool-cache/scripts/externals/7zdec.exe b/packages/tool-cache/scripts/externals/7zdec.exe new file mode 100644 index 00000000..1106aa0e Binary files /dev/null and b/packages/tool-cache/scripts/externals/7zdec.exe differ diff --git a/packages/tool-cache/scripts/externals/unzip b/packages/tool-cache/scripts/externals/unzip new file mode 100755 index 00000000..40824180 Binary files /dev/null and b/packages/tool-cache/scripts/externals/unzip differ diff --git a/packages/tool-cache/src/tool-cache.ts b/packages/tool-cache/src/tool-cache.ts new file mode 100644 index 00000000..21059e67 --- /dev/null +++ b/packages/tool-cache/src/tool-cache.ts @@ -0,0 +1,473 @@ +import * as core from '@actions/core' +import * as io from '@actions/io' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import * as httpm from 'typed-rest-client/HttpClient' +import * as semver from 'semver' +import * as uuidV4 from 'uuid/v4' +import {exec} from '@actions/exec/lib/exec' +import {ExecOptions} from '@actions/exec/lib/interfaces' +import {ok} from 'assert' + +export class HTTPError extends Error { + constructor(readonly httpStatusCode: number | undefined) { + super(`Unexpected HTTP response: ${httpStatusCode}`) + Object.setPrototypeOf(this, new.target.prototype) + } +} + +const IS_WINDOWS = process.platform === 'win32' +const userAgent = 'actions/tool-cache' + +// On load grab temp directory and cache directory and remove them from env (currently don't want to expose this) +let tempDirectory: string = process.env['RUNNER_TEMPDIRECTORY'] || '' +let cacheRoot: string = process.env['RUNNER_TOOLSDIRECTORY'] || '' +// If directories not found, place them in common temp locations +if (!tempDirectory || !cacheRoot) { + let baseLocation: string + if (IS_WINDOWS) { + // On windows use the USERPROFILE env variable + baseLocation = process.env['USERPROFILE'] || 'C:\\' + } else { + if (process.platform === 'darwin') { + baseLocation = '/Users' + } else { + baseLocation = '/home' + } + } + if (!tempDirectory) { + tempDirectory = path.join(baseLocation, 'actions', 'temp') + } + if (!cacheRoot) { + cacheRoot = path.join(baseLocation, 'actions', 'cache') + } +} + +/** + * Download a tool from an url and stream it into a file + * + * @param url url of tool to download + * @returns path to downloaded tool + */ +export async function downloadTool(url: string): Promise { + // Wrap in a promise so that we can resolve from within stream callbacks + return new Promise(async (resolve, reject) => { + try { + const http = new httpm.HttpClient(userAgent, [], { + allowRetries: true, + maxRetries: 3 + }) + const destPath = path.join(tempDirectory, uuidV4()) + + await io.mkdirP(tempDirectory) + core.debug(`Downloading ${url}`) + core.debug(`Downloading ${destPath}`) + + if (fs.existsSync(destPath)) { + throw new Error(`Destination file path ${destPath} already exists`) + } + + const response: httpm.HttpClientResponse = await http.get(url) + + if (response.message.statusCode !== 200) { + const err = new HTTPError(response.message.statusCode) + core.debug( + `Failed to download from "${url}". Code(${ + response.message.statusCode + }) Message(${response.message.statusMessage})` + ) + throw err + } + + const file: NodeJS.WritableStream = fs.createWriteStream(destPath) + file.on('open', async () => { + try { + const stream = response.message.pipe(file) + stream.on('close', () => { + core.debug('download complete') + resolve(destPath) + }) + } catch (err) { + core.debug( + `Failed to download from "${url}". Code(${ + response.message.statusCode + }) Message(${response.message.statusMessage})` + ) + reject(err) + } + }) + file.on('error', err => { + file.end() + reject(err) + }) + } catch (err) { + reject(err) + } + }) +} + +/** + * Extract a .7z file + * + * @param file path to the .7z file + * @param dest destination directory. Optional. + * @param _7zPath path to 7zr.exe. Optional, for long path support. Most .7z archives do not have this + * problem. If your .7z archive contains very long paths, you can pass the path to 7zr.exe which will + * gracefully handle long paths. By default 7zdec.exe is used because it is a very small program and is + * bundled with the tool lib. However it does not support long paths. 7zr.exe is the reduced command line + * interface, it is smaller than the full command line interface, and it does support long paths. At the + * time of this writing, it is freely available from the LZMA SDK that is available on the 7zip website. + * Be sure to check the current license agreement. If 7zr.exe is bundled with your action, then the path + * to 7zr.exe can be pass to this function. + * @returns path to the destination directory + */ +export async function extract7z( + file: string, + dest?: string, + _7zPath?: string +): Promise { + ok(IS_WINDOWS, 'extract7z() not supported on current OS') + ok(file, 'parameter "file" is required') + + dest = dest || (await _createExtractFolder(dest)) + + const originalCwd = process.cwd() + process.chdir(dest) + if (_7zPath) { + try { + const args: string[] = [ + 'x', // eXtract files with full paths + '-bb1', // -bb[0-3] : set output log level + '-bd', // disable progress indicator + '-sccUTF-8', // set charset for for console input/output + file + ] + const options: ExecOptions = { + silent: true + } + await exec(`"${_7zPath}"`, args, options) + } finally { + process.chdir(originalCwd) + } + } else { + const escapedScript = path + .join(__dirname, '..', 'scripts', 'Invoke-7zdec.ps1') + .replace(/'/g, "''") + .replace(/"|\n|\r/g, '') // double-up single quotes, remove double quotes and newlines + const escapedFile = file.replace(/'/g, "''").replace(/"|\n|\r/g, '') + const escapedTarget = dest.replace(/'/g, "''").replace(/"|\n|\r/g, '') + const command = `& '${escapedScript}' -Source '${escapedFile}' -Target '${escapedTarget}'` + const args: string[] = [ + '-NoLogo', + '-Sta', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Unrestricted', + '-Command', + command + ] + const options: ExecOptions = { + silent: true + } + try { + const powershellPath: string = await io.which('powershell', true) + await exec(`"${powershellPath}"`, args, options) + } finally { + process.chdir(originalCwd) + } + } + + return dest +} + +/** + * Extract a tar + * + * @param file path to the tar + * @param dest destination directory. Optional. + * @returns path to the destination directory + */ +export async function extractTar(file: string, dest?: string): Promise { + if (!file) { + throw new Error("parameter 'file' is required") + } + + dest = dest || (await _createExtractFolder(dest)) + const tarPath: string = await io.which('tar', true) + await exec(`"${tarPath}"`, ['xzC', dest, '-f', file]) + + return dest +} + +/** + * Extract a zip + * + * @param file path to the zip + * @param dest destination directory. Optional. + * @returns path to the destination directory + */ +export async function extractZip(file: string, dest?: string): Promise { + if (!file) { + throw new Error("parameter 'file' is required") + } + + dest = dest || (await _createExtractFolder(dest)) + + if (IS_WINDOWS) { + await extractZipWin(file, dest) + } else { + await extractZipNix(file, dest) + } + + return dest +} + +async function extractZipWin(file: string, dest: string): Promise { + // build the powershell command + const escapedFile = file.replace(/'/g, "''").replace(/"|\n|\r/g, '') // double-up single quotes, remove double quotes and newlines + const escapedDest = dest.replace(/'/g, "''").replace(/"|\n|\r/g, '') + const command = `$ErrorActionPreference = 'Stop' ; try { Add-Type -AssemblyName System.IO.Compression.FileSystem } catch { } ; [System.IO.Compression.ZipFile]::ExtractToDirectory('${escapedFile}', '${escapedDest}')` + + // run powershell + const powershellPath = await io.which('powershell') + const args = [ + '-NoLogo', + '-Sta', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Unrestricted', + '-Command', + command + ] + await exec(`"${powershellPath}"`, args) +} + +async function extractZipNix(file: string, dest: string): Promise { + const unzipPath = path.join(__dirname, '..', 'scripts', 'externals', 'unzip') + await exec(`"${unzipPath}"`, [file], {cwd: dest}) +} + +/** + * Caches a directory and installs it into the tool cacheDir + * + * @param sourceDir the directory to cache into tools + * @param tool tool name + * @param version version of the tool. semver format + * @param arch architecture of the tool. Optional. Defaults to machine architecture + */ +export async function cacheDir( + sourceDir: string, + tool: string, + version: string, + arch?: string +): Promise { + version = semver.clean(version) || version + arch = arch || os.arch() + core.debug(`Caching tool ${tool} ${version} ${arch}`) + + core.debug(`source dir: ${sourceDir}`) + if (!fs.statSync(sourceDir).isDirectory()) { + throw new Error('sourceDir is not a directory') + } + + // Create the tool dir + const destPath: string = await _createToolPath(tool, version, arch) + // copy each child item. do not move. move can fail on Windows + // due to anti-virus software having an open handle on a file. + for (const itemName of fs.readdirSync(sourceDir)) { + const s = path.join(sourceDir, itemName) + await io.cp(s, destPath, {recursive: true}) + } + + // write .complete + _completeToolPath(tool, version, arch) + + return destPath +} + +/** + * Caches a downloaded file (GUID) and installs it + * into the tool cache with a given targetName + * + * @param sourceFile the file to cache into tools. Typically a result of downloadTool which is a guid. + * @param targetFile the name of the file name in the tools directory + * @param tool tool name + * @param version version of the tool. semver format + * @param arch architecture of the tool. Optional. Defaults to machine architecture + */ +export async function cacheFile( + sourceFile: string, + targetFile: string, + tool: string, + version: string, + arch?: string +): Promise { + version = semver.clean(version) || version + arch = arch || os.arch() + core.debug(`Caching tool ${tool} ${version} ${arch}`) + + core.debug(`source file: ${sourceFile}`) + if (!fs.statSync(sourceFile).isFile()) { + throw new Error('sourceFile is not a file') + } + + // create the tool dir + const destFolder: string = await _createToolPath(tool, version, arch) + + // copy instead of move. move can fail on Windows due to + // anti-virus software having an open handle on a file. + const destPath: string = path.join(destFolder, targetFile) + core.debug(`destination file ${destPath}`) + await io.cp(sourceFile, destPath) + + // write .complete + _completeToolPath(tool, version, arch) + + return destFolder +} + +/** + * finds the path to a tool in the local installed tool cache + * + * @param toolName name of the tool + * @param versionSpec version of the tool + * @param arch optional arch. defaults to arch of computer + */ +export function find( + toolName: string, + versionSpec: string, + arch?: string +): string { + if (!toolName) { + throw new Error('toolName parameter is required') + } + + if (!versionSpec) { + throw new Error('versionSpec parameter is required') + } + + arch = arch || os.arch() + + // attempt to resolve an explicit version + if (!_isExplicitVersion(versionSpec)) { + const localVersions: string[] = _findLocalToolVersions(toolName, arch) + const match = _evaluateVersions(localVersions, versionSpec) + versionSpec = match + } + + // check for the explicit version in the cache + let toolPath = '' + if (versionSpec) { + versionSpec = semver.clean(versionSpec) || '' + const cachePath = path.join(cacheRoot, toolName, versionSpec, arch) + core.debug(`checking cache: ${cachePath}`) + if (fs.existsSync(cachePath) && fs.existsSync(`${cachePath}.complete`)) { + core.debug(`Found tool in cache ${toolName} ${versionSpec} ${arch}`) + toolPath = cachePath + } else { + core.debug('not found') + } + } + return toolPath +} + +async function _createExtractFolder(dest?: string): Promise { + if (!dest) { + // create a temp dir + dest = path.join(tempDirectory, uuidV4()) + } + await io.mkdirP(dest) + return dest +} + +async function _createToolPath( + tool: string, + version: string, + arch?: string +): Promise { + const folderPath = path.join( + cacheRoot, + tool, + semver.clean(version) || version, + arch || '' + ) + core.debug(`destination ${folderPath}`) + const markerPath = `${folderPath}.complete` + await io.rmRF(folderPath) + await io.rmRF(markerPath) + await io.mkdirP(folderPath) + return folderPath +} + +function _completeToolPath(tool: string, version: string, arch?: string): void { + const folderPath = path.join( + cacheRoot, + tool, + semver.clean(version) || version, + arch || '' + ) + const markerPath = `${folderPath}.complete` + fs.writeFileSync(markerPath, '') + core.debug('finished caching tool') +} + +function _isExplicitVersion(versionSpec: string): boolean { + const c = semver.clean(versionSpec) || '' + core.debug(`isExplicit: ${c}`) + + const valid = semver.valid(c) != null + core.debug(`explicit? ${valid}`) + + return valid +} + +function _evaluateVersions(versions: string[], versionSpec: string): string { + let version = '' + core.debug(`evaluating ${versions.length} versions`) + versions = versions.sort((a, b) => { + if (semver.gt(a, b)) { + return 1 + } + return -1 + }) + for (let i = versions.length - 1; i >= 0; i--) { + const potential: string = versions[i] + const satisfied: boolean = semver.satisfies(potential, versionSpec) + if (satisfied) { + version = potential + break + } + } + + if (version) { + core.debug(`matched: ${version}`) + } else { + core.debug('match not found') + } + + return version +} + +function _findLocalToolVersions(toolName: string, arch?: string): string[] { + const versions: string[] = [] + + arch = arch || os.arch() + const toolPath = path.join(cacheRoot, toolName) + + if (fs.existsSync(toolPath)) { + const children: string[] = fs.readdirSync(toolPath) + for (const child of children) { + if (_isExplicitVersion(child)) { + const fullPath = path.join(toolPath, child, arch || '') + if (fs.existsSync(fullPath) && fs.existsSync(`${fullPath}.complete`)) { + versions.push(child) + } + } + } + } + + return versions +} diff --git a/packages/tool-cache/tsconfig.json b/packages/tool-cache/tsconfig.json new file mode 100644 index 00000000..a8b812a6 --- /dev/null +++ b/packages/tool-cache/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./lib", + "rootDir": "./src" + }, + "include": [ + "./src" + ] +} \ No newline at end of file