mirror of https://github.com/actions/toolkit
Introduce universal archive extractor into @actions/tool-cache
parent
92695f58da
commit
39f7658b55
|
@ -39,6 +39,15 @@ else {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
the Archive helper can be used as a shortcut for both download and extraction of an archive:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const {Archive} = require('@actions/tool-cache');
|
||||||
|
|
||||||
|
const node12Archive = await Archive.retrieve('https://nodejs.org/dist/v12.7.0/node-v12.7.0-linux-x64.tar.gz')
|
||||||
|
const node12ExtractedFolder = await node12Archive.extract('path/to/extract/to')
|
||||||
|
```
|
||||||
|
|
||||||
#### Cache
|
#### Cache
|
||||||
|
|
||||||
Finally, you can cache these directories in our tool-cache. This is useful if you want to switch back and forth between versions of a tool, or save a tool between runs for self-hosted runners.
|
Finally, you can cache these directories in our tool-cache. This is useful if you want to switch back and forth between versions of a tool, or save a tool between runs for self-hosted runners.
|
||||||
|
|
|
@ -0,0 +1,122 @@
|
||||||
|
import {Archive} from '../src/tool-cache'
|
||||||
|
import * as tc from '../src/tool-cache'
|
||||||
|
|
||||||
|
describe('archive-extractor', () => {
|
||||||
|
describe('getArchiveType', () => {
|
||||||
|
it('detects 7z', async () => {
|
||||||
|
const type = await Archive.getArchiveType(
|
||||||
|
require.resolve('./data/test.7z')
|
||||||
|
)
|
||||||
|
expect(type).toEqual('7z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects tar', async () => {
|
||||||
|
const type1 = await Archive.getArchiveType(
|
||||||
|
require.resolve('./data/test.tar.gz')
|
||||||
|
)
|
||||||
|
expect(type1).toEqual('tar')
|
||||||
|
|
||||||
|
const type2 = await Archive.getArchiveType(
|
||||||
|
require.resolve('./data/test.tar.xz')
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(type2).toEqual('tar')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects zip', async () => {
|
||||||
|
const type = await Archive.getArchiveType(
|
||||||
|
require.resolve('./data/test.zip')
|
||||||
|
)
|
||||||
|
expect(type).toEqual('zip')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on unsupported type', async () => {
|
||||||
|
await expect(
|
||||||
|
Archive.getArchiveType(require.resolve('./data/test.notarchive'))
|
||||||
|
).rejects.toThrow('Unable to determine archive type')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on non-existent file', async () => {
|
||||||
|
await expect(Archive.getArchiveType('non-existent-file')).rejects.toThrow(
|
||||||
|
'Unable to open non-existent-file'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('retrieveArchive', () => {
|
||||||
|
it('downloads archive', async () => {
|
||||||
|
const downloadToolSpy = jest.spyOn(tc, 'downloadTool')
|
||||||
|
|
||||||
|
downloadToolSpy.mockImplementation(async () =>
|
||||||
|
Promise.resolve('dummy-path')
|
||||||
|
)
|
||||||
|
|
||||||
|
await Archive.retrieve('https://test', {
|
||||||
|
type: 'zip',
|
||||||
|
downloadPath: 'dummy-download-path'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(downloadToolSpy).toHaveBeenLastCalledWith(
|
||||||
|
'https://test',
|
||||||
|
'dummy-download-path',
|
||||||
|
undefined,
|
||||||
|
undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
downloadToolSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts zip', async () => {
|
||||||
|
const extractZipSpy = jest.spyOn(tc, 'extractZip')
|
||||||
|
|
||||||
|
extractZipSpy.mockImplementation(async () =>
|
||||||
|
Promise.resolve('dummy-path')
|
||||||
|
)
|
||||||
|
|
||||||
|
const archive = new Archive.ZipArchive('dummy-path')
|
||||||
|
await archive.extract('dummy-dest')
|
||||||
|
|
||||||
|
expect(extractZipSpy).toHaveBeenLastCalledWith('dummy-path', 'dummy-dest')
|
||||||
|
|
||||||
|
extractZipSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts tar', async () => {
|
||||||
|
const extractTarSpy = jest.spyOn(tc, 'extractTar')
|
||||||
|
|
||||||
|
extractTarSpy.mockImplementation(async () =>
|
||||||
|
Promise.resolve('dummy-path')
|
||||||
|
)
|
||||||
|
|
||||||
|
const archive = new Archive.TarArchive('dummy-path')
|
||||||
|
|
||||||
|
await archive.extract('dummy-dest', ['flag1', 'flag2'])
|
||||||
|
|
||||||
|
expect(extractTarSpy).toHaveBeenLastCalledWith(
|
||||||
|
'dummy-path',
|
||||||
|
'dummy-dest',
|
||||||
|
['flag1', 'flag2']
|
||||||
|
)
|
||||||
|
|
||||||
|
extractTarSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts 7z', async () => {
|
||||||
|
const extract7zSpy = jest.spyOn(tc, 'extract7z')
|
||||||
|
|
||||||
|
extract7zSpy.mockImplementation(async () => Promise.resolve('dummy-path'))
|
||||||
|
|
||||||
|
const archive = new Archive.SevenZipArchive('dummy-path')
|
||||||
|
|
||||||
|
await archive.extract('dummy-dest', 'dummy-7z-path')
|
||||||
|
|
||||||
|
expect(extract7zSpy).toHaveBeenLastCalledWith(
|
||||||
|
'dummy-path',
|
||||||
|
'dummy-dest',
|
||||||
|
'dummy-7z-path'
|
||||||
|
)
|
||||||
|
|
||||||
|
extract7zSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1 @@
|
||||||
|
not an archive
|
Binary file not shown.
|
@ -0,0 +1,83 @@
|
||||||
|
import {extractZip, extract7z, extractTar, extractXar} from '../tool-cache'
|
||||||
|
|
||||||
|
abstract class ArchiveBase {
|
||||||
|
abstract type: 'zip' | 'tar' | '7z' | 'xar'
|
||||||
|
path: string
|
||||||
|
|
||||||
|
constructor(path: string) {
|
||||||
|
this.path = path
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract extract(
|
||||||
|
dest?: string,
|
||||||
|
flags?: string | string[] | undefined
|
||||||
|
): Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ZipArchive extends ArchiveBase {
|
||||||
|
type: 'zip' = 'zip'
|
||||||
|
|
||||||
|
constructor(public path: string) {
|
||||||
|
super(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async extract(dest?: string): Promise<string> {
|
||||||
|
return await extractZip(this.path, dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TarArchive extends ArchiveBase {
|
||||||
|
type: 'tar' = 'tar'
|
||||||
|
|
||||||
|
constructor(public path: string) {
|
||||||
|
super(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async extract(
|
||||||
|
dest?: string,
|
||||||
|
flags?: string | string[] | undefined
|
||||||
|
): Promise<string> {
|
||||||
|
return await extractTar(this.path, dest, flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SevenZipArchive extends ArchiveBase {
|
||||||
|
type: '7z' = '7z'
|
||||||
|
|
||||||
|
constructor(public path: string) {
|
||||||
|
super(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async extract(dest?: string, _7zPath?: string | undefined): Promise<string> {
|
||||||
|
return await extract7z(this.path, dest, _7zPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class XarArchive extends ArchiveBase {
|
||||||
|
type: 'xar' = 'xar'
|
||||||
|
|
||||||
|
constructor(public path: string) {
|
||||||
|
super(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async extract(
|
||||||
|
dest?: string,
|
||||||
|
flags?: string | string[] | undefined
|
||||||
|
): Promise<string> {
|
||||||
|
return await extractXar(this.path, dest, flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Archive = ZipArchive | TarArchive | SevenZipArchive | XarArchive
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
|
||||||
|
export const isZipArchive = (archive: Archive): archive is ZipArchive =>
|
||||||
|
archive.type === 'zip'
|
||||||
|
export const isTarArchive = (archive: Archive): archive is TarArchive =>
|
||||||
|
archive.type === 'tar'
|
||||||
|
export const isSevenZipArchive = (
|
||||||
|
archive: Archive
|
||||||
|
): archive is SevenZipArchive => archive.type === '7z'
|
||||||
|
export const isXarArchive = (archive: Archive): archive is XarArchive =>
|
||||||
|
archive.type === 'xar'
|
|
@ -0,0 +1,107 @@
|
||||||
|
import fs from 'fs'
|
||||||
|
import {ArchiveType} from './types'
|
||||||
|
|
||||||
|
const MAX_READ_SIZE = 4096
|
||||||
|
const MAX_CHUNK_SIZE = 1024
|
||||||
|
|
||||||
|
const SIGNATURES = {
|
||||||
|
zip: '504b0304',
|
||||||
|
gz: '1f8b08',
|
||||||
|
bz2: '425a68',
|
||||||
|
xz: 'fd377a585a00',
|
||||||
|
'7z': '377abcaf271c',
|
||||||
|
xar: '78617221', // 'xar!' in hex
|
||||||
|
tar: '7573746172' // 'ustar' in hex
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const getArchiveTypeFromBuffer = (buffer: Buffer): ArchiveType | null => {
|
||||||
|
for (const [type, signature] of Object.entries(SIGNATURES)) {
|
||||||
|
if (!buffer.toString('hex').includes(signature)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['bz2', 'gz', 'tar', 'xz'].includes(type)) {
|
||||||
|
return 'tar'
|
||||||
|
}
|
||||||
|
|
||||||
|
return type as ArchiveType
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const readStreamFromDescriptor = (fd: number): fs.ReadStream =>
|
||||||
|
fs.createReadStream('', {
|
||||||
|
fd,
|
||||||
|
start: 0,
|
||||||
|
end: MAX_READ_SIZE,
|
||||||
|
highWaterMark: MAX_CHUNK_SIZE
|
||||||
|
})
|
||||||
|
|
||||||
|
class LimitedArray<T> {
|
||||||
|
private _array: T[] = []
|
||||||
|
constructor(private maxLength: number) {}
|
||||||
|
push(item: T): void {
|
||||||
|
if (this._array.length >= this.maxLength) {
|
||||||
|
this._array.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
this._array.push(item)
|
||||||
|
}
|
||||||
|
get array(): T[] {
|
||||||
|
return [...this._array]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getArchiveType = async (filePath: string): Promise<ArchiveType> =>
|
||||||
|
new Promise((resolve, reject) =>
|
||||||
|
fs.open(filePath, 'r', (error, fd) => {
|
||||||
|
if (fd === undefined) {
|
||||||
|
reject(new Error(`Unable to open ${filePath}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
fs.close(fd, () => reject(error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffers = new LimitedArray<Buffer>(2)
|
||||||
|
const readStream = readStreamFromDescriptor(fd)
|
||||||
|
|
||||||
|
const closeEverythingAndResolve = (result: ArchiveType): void => {
|
||||||
|
readStream.close(() => {
|
||||||
|
fs.close(fd, () => resolve(result as '7z' | 'zip' | 'xar' | 'tar'))
|
||||||
|
})
|
||||||
|
readStream.push(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeEverythingAndReject = (error?: Error): void => {
|
||||||
|
readStream.close(() => {
|
||||||
|
fs.close(fd, () =>
|
||||||
|
reject(
|
||||||
|
error ?? Error(`Unable to determine archive type of ${filePath}`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
readStream.push(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(closeEverythingAndReject, 100)
|
||||||
|
|
||||||
|
readStream
|
||||||
|
.on('data', chunk => {
|
||||||
|
if (!(chunk instanceof Buffer)) return closeEverythingAndReject()
|
||||||
|
|
||||||
|
buffers.push(chunk)
|
||||||
|
const type = getArchiveTypeFromBuffer(Buffer.concat(buffers.array))
|
||||||
|
|
||||||
|
if (type !== null) {
|
||||||
|
return closeEverythingAndResolve(type)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on('end', () => closeEverythingAndReject())
|
||||||
|
.on('close', () => closeEverythingAndReject())
|
||||||
|
.on('error', closeEverythingAndReject)
|
||||||
|
})
|
||||||
|
)
|
|
@ -0,0 +1,12 @@
|
||||||
|
export {retrieve} from './retrieve-archive'
|
||||||
|
export {getArchiveType} from './get-archive-type'
|
||||||
|
export {
|
||||||
|
ZipArchive,
|
||||||
|
TarArchive,
|
||||||
|
SevenZipArchive,
|
||||||
|
XarArchive,
|
||||||
|
isZipArchive,
|
||||||
|
isTarArchive,
|
||||||
|
isSevenZipArchive,
|
||||||
|
isXarArchive
|
||||||
|
} from './archive-types'
|
|
@ -0,0 +1,39 @@
|
||||||
|
import {
|
||||||
|
ZipArchive,
|
||||||
|
TarArchive,
|
||||||
|
SevenZipArchive,
|
||||||
|
XarArchive
|
||||||
|
} from './archive-types'
|
||||||
|
import {downloadTool} from '../tool-cache'
|
||||||
|
import {getArchiveType} from './get-archive-type'
|
||||||
|
import {PredictTypeByOptions, RetrieveArchiveOptions} from './types'
|
||||||
|
|
||||||
|
export const retrieve = async <O extends RetrieveArchiveOptions>(
|
||||||
|
url: string,
|
||||||
|
options?: O
|
||||||
|
): Promise<PredictTypeByOptions<O>> => {
|
||||||
|
const path = await downloadTool(
|
||||||
|
url,
|
||||||
|
options?.downloadPath,
|
||||||
|
options?.auth,
|
||||||
|
options?.headers
|
||||||
|
)
|
||||||
|
|
||||||
|
const archiveType =
|
||||||
|
options?.type === 'auto' || !options?.type
|
||||||
|
? await getArchiveType(path)
|
||||||
|
: options.type
|
||||||
|
|
||||||
|
switch (archiveType) {
|
||||||
|
case 'zip':
|
||||||
|
return new ZipArchive(path) as PredictTypeByOptions<O>
|
||||||
|
case 'tar':
|
||||||
|
return new TarArchive(path) as PredictTypeByOptions<O>
|
||||||
|
case '7z':
|
||||||
|
return new SevenZipArchive(path) as PredictTypeByOptions<O>
|
||||||
|
case 'xar':
|
||||||
|
return new XarArchive(path) as PredictTypeByOptions<O>
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported archive type: ${archiveType}`)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
import {OutgoingHttpHeaders} from 'http'
|
||||||
|
import {
|
||||||
|
SevenZipArchive,
|
||||||
|
TarArchive,
|
||||||
|
XarArchive,
|
||||||
|
ZipArchive
|
||||||
|
} from './archive-types'
|
||||||
|
|
||||||
|
export type ArchiveType = 'zip' | 'tar' | '7z' | 'xar'
|
||||||
|
|
||||||
|
interface RetrieveArchiveOptionsBase {
|
||||||
|
downloadPath?: string
|
||||||
|
type?: ArchiveType | 'auto'
|
||||||
|
auth?: string | undefined
|
||||||
|
headers?: OutgoingHttpHeaders | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetrieveZipArchiveOptions extends RetrieveArchiveOptionsBase {
|
||||||
|
type: 'zip'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetrieveTarArchiveOptions extends RetrieveArchiveOptionsBase {
|
||||||
|
type: 'tar'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Retrieve7zArchiveOptions extends RetrieveArchiveOptionsBase {
|
||||||
|
type: '7z'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetrieveXarArchiveOptions extends RetrieveArchiveOptionsBase {
|
||||||
|
type: 'xar'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetrieveUnknownArchiveOptions
|
||||||
|
extends RetrieveArchiveOptionsBase {
|
||||||
|
type?: 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RetrieveArchiveOptions =
|
||||||
|
| RetrieveZipArchiveOptions
|
||||||
|
| RetrieveTarArchiveOptions
|
||||||
|
| Retrieve7zArchiveOptions
|
||||||
|
| RetrieveXarArchiveOptions
|
||||||
|
| RetrieveUnknownArchiveOptions
|
||||||
|
|
||||||
|
export type PredictTypeByOptions<O extends RetrieveArchiveOptions> =
|
||||||
|
O extends RetrieveZipArchiveOptions
|
||||||
|
? ZipArchive
|
||||||
|
: O extends Retrieve7zArchiveOptions
|
||||||
|
? SevenZipArchive
|
||||||
|
: O extends RetrieveTarArchiveOptions
|
||||||
|
? TarArchive
|
||||||
|
: O extends RetrieveXarArchiveOptions
|
||||||
|
? XarArchive
|
||||||
|
: ZipArchive | TarArchive | SevenZipArchive | XarArchive
|
|
@ -775,3 +775,8 @@ function _getGlobal<T>(key: string, defaultValue: T): T {
|
||||||
function _unique<T>(values: T[]): T[] {
|
function _unique<T>(values: T[]): T[] {
|
||||||
return Array.from(new Set(values))
|
return Array.from(new Set(values))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive helper exports
|
||||||
|
*/
|
||||||
|
export * as Archive from './archive'
|
||||||
|
|
Loading…
Reference in New Issue