Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The created cache tarballs are placed in `$RUNNER_TOOL_CACHE/$GITHUB_REPOSITORY`
`GITHUB_REPOSITORY` will be set to the name (`org/repo`) of the repository from where the action is executed.
`RUNNER_TOOL_CACHE` defaults to `/opt/hostedtoolcache`. This should point to a folder on a persistent storage.

By default, `compression-mode` is `auto`, which keeps the existing behavior of using zstd when available and gzip otherwise. You can set it to `gzip`, `zstd`, or `lz4` to force a specific mode. Changing this mode changes the archive format, so existing caches created with another compression mode will not be restored. Archive filenames use `cache.tgz` for gzip, `cache.tzst` for zstd, and `cache.tlz4` for lz4. When using `lz4`, make sure the `lz4` command is installed on the runner.

### Example cache workflow

````yaml
Expand Down Expand Up @@ -40,6 +42,21 @@ jobs:

````

### Compression mode

The action defaults to `compression-mode: auto`. To force lz4 compression on runners where `lz4` is installed:

````yaml
- uses: maxnowack/local-cache@v2
with:
path: prime-numbers
key: ${{ runner.os }}-primes
compression-mode: lz4
compression-args: --fast=2
````

`compression-args` are passed to the active compression program. In a save step they tune compression; in a restore step they tune decompression.

## Contributing

Contributions to the project are welcome. Feel free to fork and improve. I do my best accept pull requests in a timely manor, especially when tests and updated docs are included.
Expand Down
91 changes: 91 additions & 0 deletions __tests__/cacheUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import * as exec from '@actions/exec'
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import {
getCacheFileName,
getCacheSizeInBytes,
getCompressionMethod,
} from '../src/cache/cacheUtils'
import {
CacheFilename,
CompressionMethod,
} from '../src/cache/constants'
import { Inputs } from '../src/constants'
import * as testUtils from '../src/utils/testUtils'

jest.mock('@actions/exec')

const execMock = exec.exec as jest.MockedFunction<typeof exec.exec>
let originalWorkspace: string | undefined

function mockVersionOutput(output: string): void {
execMock.mockImplementation(async (commandLine, args, options) => {
options?.listeners?.stdout?.(Buffer.from(output))
return 0
})
}

afterEach(() => {
process.env.GITHUB_WORKSPACE = originalWorkspace
testUtils.clearInputs()
jest.clearAllMocks()
})

test('getCompressionMethod returns lz4 when selected', async () => {
testUtils.setInput(Inputs.CompressionMode, 'lz4')

await expect(getCompressionMethod()).resolves.toBe(CompressionMethod.Lz4)
expect(execMock).not.toHaveBeenCalled()
})

test('getCompressionMethod returns zstd when selected', async () => {
testUtils.setInput(Inputs.CompressionMode, 'zstd')

await expect(getCompressionMethod()).resolves.toBe(
CompressionMethod.ZstdWithoutLong,
)
expect(execMock).not.toHaveBeenCalled()
})

test('getCompressionMethod auto-selects zstd when available', async () => {
mockVersionOutput('1.5.5')

await expect(getCompressionMethod()).resolves.toBe(
CompressionMethod.ZstdWithoutLong,
)
})

test('getCompressionMethod auto-selects gzip when zstd is unavailable', async () => {
mockVersionOutput('')

await expect(getCompressionMethod()).resolves.toBe(CompressionMethod.Gzip)
})

test('getCompressionMethod rejects unsupported compression modes', async () => {
testUtils.setInput(Inputs.CompressionMode, 'snappy')

await expect(getCompressionMethod()).rejects.toThrow(
'Unsupported compression mode: snappy',
)
})

test('getCacheFileName returns lz4 archive filename', () => {
expect(getCacheFileName(CompressionMethod.Lz4)).toBe(CacheFilename.Lz4)
})

test('getCacheSizeInBytes returns recursive source size', () => {
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'local-cache-size-'))
originalWorkspace = process.env.GITHUB_WORKSPACE
process.env.GITHUB_WORKSPACE = workspace

try {
fs.mkdirSync(path.join(workspace, 'cache-dir'))
fs.writeFileSync(path.join(workspace, 'cache-dir', 'first.txt'), 'hello')
fs.writeFileSync(path.join(workspace, 'second.txt'), 'world!')

expect(getCacheSizeInBytes(['cache-dir', 'second.txt'])).toBe(11)
} finally {
fs.rmSync(workspace, { recursive: true, force: true })
}
})
126 changes: 126 additions & 0 deletions __tests__/tar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import * as exec from '@actions/exec'
import * as io from '@actions/io'
import * as cacheUtils from '../src/cache/cacheUtils'
import { CompressionMethod } from '../src/cache/constants'
import {
createTar,
extractTar,
listTar,
} from '../src/cache/tar'
import { Inputs } from '../src/constants'
import * as testUtils from '../src/utils/testUtils'

jest.mock('@actions/exec')
jest.mock('@actions/io')

const execMock = exec.exec as jest.MockedFunction<typeof exec.exec>
const mkdirPMock = io.mkdirP as jest.MockedFunction<typeof io.mkdirP>
const whichMock = io.which as jest.MockedFunction<typeof io.which>

let archiveFolder = ''
let originalWorkspace: string | undefined

beforeEach(() => {
archiveFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'local-cache-tar-'))
originalWorkspace = process.env.GITHUB_WORKSPACE
process.env.GITHUB_WORKSPACE = process.cwd()

execMock.mockResolvedValue(0)
mkdirPMock.mockResolvedValue()
whichMock.mockResolvedValue('tar')
jest.spyOn(cacheUtils, 'getGnuTarPathOnWindows').mockResolvedValue('tar')
})

afterEach(() => {
process.env.GITHUB_WORKSPACE = originalWorkspace
testUtils.clearInputs()
fs.rmSync(archiveFolder, { recursive: true, force: true })
jest.restoreAllMocks()
})

test('createTar uses lz4 compression program', async () => {
await createTar(archiveFolder, ['node_modules'], CompressionMethod.Lz4)

expect(execMock).toHaveBeenCalledTimes(1)
expect(execMock.mock.calls[0][0]).toContain('--use-compress-program "lz4"')
expect(execMock.mock.calls[0][0]).toContain('cache.tlz4')
expect(execMock.mock.calls[0][0]).toContain('--files-from manifest.txt')
expect(execMock.mock.calls[0][2]?.cwd).toBe(archiveFolder)
})

test('extractTar uses lz4 decompression program', async () => {
await extractTar(path.join(archiveFolder, 'cache.tlz4'), CompressionMethod.Lz4)

expect(execMock).toHaveBeenCalledTimes(1)
expect(execMock.mock.calls[0][0]).toContain('--use-compress-program')
expect(execMock.mock.calls[0][0]).toContain('lz4 -d')
expect(execMock.mock.calls[0][0]).toContain('cache.tlz4')
})

test('listTar uses lz4 decompression program', async () => {
await listTar(path.join(archiveFolder, 'cache.tlz4'), CompressionMethod.Lz4)

expect(execMock).toHaveBeenCalledTimes(1)
expect(execMock.mock.calls[0][0]).toContain('--use-compress-program')
expect(execMock.mock.calls[0][0]).toContain('lz4 -d')
expect(execMock.mock.calls[0][0]).toContain('cache.tlz4')
})

test('createTar forwards compression args to lz4', async () => {
testUtils.setInput(Inputs.CompressionArgs, '--fast=2')

await createTar(archiveFolder, ['node_modules'], CompressionMethod.Lz4)

expect(execMock.mock.calls[0][0]).toContain(
'--use-compress-program "lz4 --fast=2"',
)
})

test('createTar forwards compression args to zstd', async () => {
testUtils.setInput(Inputs.CompressionArgs, '--adapt')

await createTar(archiveFolder, ['node_modules'], CompressionMethod.Zstd)

expect(execMock.mock.calls[0][0]).toMatch(/zstd(mt)?/)
expect(execMock.mock.calls[0][0]).toContain('--adapt')
})

test('createTar forwards compression args to gzip', async () => {
testUtils.setInput(Inputs.CompressionArgs, '-1')

await createTar(archiveFolder, ['node_modules'], CompressionMethod.Gzip)

expect(execMock.mock.calls[0][0]).toContain(
'--use-compress-program "gzip -1"',
)
})

test('extractTar forwards compression args to lz4 decompression', async () => {
testUtils.setInput(Inputs.CompressionArgs, '--no-sparse')

await extractTar(path.join(archiveFolder, 'cache.tlz4'), CompressionMethod.Lz4)

expect(execMock.mock.calls[0][0]).toContain('lz4 -d --no-sparse')
})

test('extractTar forwards compression args to zstd decompression', async () => {
testUtils.setInput(Inputs.CompressionArgs, '--long=31')

await extractTar(path.join(archiveFolder, 'cache.tzst'), CompressionMethod.Zstd)

expect(execMock.mock.calls[0][0]).toMatch(/unzstd|zstd -d/)
expect(execMock.mock.calls[0][0]).toContain('--long=31')
})

test('extractTar forwards compression args to gzip decompression', async () => {
testUtils.setInput(Inputs.CompressionArgs, '--verbose')

await extractTar(path.join(archiveFolder, 'cache.tgz'), CompressionMethod.Gzip)

expect(execMock.mock.calls[0][0]).toContain(
'--use-compress-program "gzip -d --verbose"',
)
})
7 changes: 7 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ inputs:
description: 'Check if a cache entry exists for the given input(s) (key, restore-keys) without downloading the cache'
default: 'false'
required: false
compression-mode:
description: 'Compression mode to use for cache archives. Supported values are auto, gzip, zstd, and lz4.'
default: 'auto'
required: false
compression-args:
description: 'Additional arguments to pass to the compression program when saving or restoring a cache archive.'
required: false
outputs:
cache-hit:
description: 'A boolean value to indicate if cache was found and restored'
Expand Down
2 changes: 2 additions & 0 deletions dist/cache/cacheUtils.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import * as fs from 'fs';
import { CompressionMethod } from './constants';
export declare function createTempDirectory(): Promise<string>;
export declare function getArchiveFileSizeInBytes(filePath: string): number;
export declare function getCacheSizeInBytes(paths: string[]): number;
export declare function resolvePaths(patterns: string[]): Promise<string[]>;
export declare function unlinkFile(filePath: fs.PathLike): Promise<void>;
export declare function getCompressionMethod(): Promise<CompressionMethod>;
export declare function getCacheFileName(compressionMethod: CompressionMethod): string;
export declare function getCompressionArgs(): string;
export declare function getGnuTarPathOnWindows(): Promise<string>;
export declare function assertDefined<T>(name: string, value?: T): T;
export declare function isGhes(): boolean;
61 changes: 52 additions & 9 deletions dist/cache/cacheUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ var __importStar = (this && this.__importStar) || function (mod) {
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.isGhes = exports.assertDefined = exports.getGnuTarPathOnWindows = exports.getCacheFileName = exports.getCompressionMethod = exports.unlinkFile = exports.resolvePaths = exports.getArchiveFileSizeInBytes = exports.createTempDirectory = void 0;
exports.isGhes = exports.assertDefined = exports.getGnuTarPathOnWindows = exports.getCompressionArgs = exports.getCacheFileName = exports.getCompressionMethod = exports.unlinkFile = exports.resolvePaths = exports.getCacheSizeInBytes = exports.getArchiveFileSizeInBytes = exports.createTempDirectory = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const util = __importStar(require("util"));
Expand All @@ -33,7 +33,8 @@ const glob = __importStar(require("@actions/glob"));
const io = __importStar(require("@actions/io"));
const semver = __importStar(require("semver"));
const uuid_1 = require("uuid");
const constants_1 = require("./constants");
const constants_1 = require("../constants");
const constants_2 = require("./constants");
// From https://github.com/actions/toolkit/blob/main/packages/tool-cache/src/tool-cache.ts#L23
async function createTempDirectory() {
const IS_WINDOWS = process.platform === 'win32';
Expand Down Expand Up @@ -61,6 +62,26 @@ function getArchiveFileSizeInBytes(filePath) {
return fs.statSync(filePath).size;
}
exports.getArchiveFileSizeInBytes = getArchiveFileSizeInBytes;
function getPathSizeInBytes(filePath) {
const stats = fs.lstatSync(filePath);
if (!stats.isDirectory()) {
return stats.size;
}
return fs.readdirSync(filePath).reduce((total, entry) => total + getPathSizeInBytes(path.join(filePath, entry)), 0);
}
function getCacheSizeInBytes(paths) {
const workspace = process.env.GITHUB_WORKSPACE ?? process.cwd();
return paths.reduce((total, cachePath) => {
const filePath = path.isAbsolute(cachePath)
? cachePath
: path.join(workspace, cachePath);
if (!fs.existsSync(filePath)) {
return total;
}
return total + getPathSizeInBytes(filePath);
}, 0);
}
exports.getCacheSizeInBytes = getCacheSizeInBytes;
async function resolvePaths(patterns) {
const paths = [];
const workspace = process.env.GITHUB_WORKSPACE ?? process.cwd();
Expand Down Expand Up @@ -118,24 +139,46 @@ async function getVersion(app, additionalArgs = []) {
}
// Use zstandard if possible to maximize cache performance
async function getCompressionMethod() {
const compressionMode = core.getInput(constants_1.Inputs.CompressionMode) || 'auto';
switch (compressionMode.toLowerCase()) {
case 'auto':
break;
case constants_2.CompressionMethod.Gzip:
return constants_2.CompressionMethod.Gzip;
case constants_2.CompressionMethod.Lz4:
return constants_2.CompressionMethod.Lz4;
case constants_2.CompressionMethod.Zstd:
return constants_2.CompressionMethod.ZstdWithoutLong;
default:
throw new Error(`Unsupported compression mode: ${compressionMode}. Supported modes are: auto, gzip, zstd, lz4.`);
}
const versionOutput = await getVersion('zstd', ['--quiet']);
const version = semver.clean(versionOutput);
core.debug(`zstd version: ${version}`);
if (versionOutput === '') {
return constants_1.CompressionMethod.Gzip;
return constants_2.CompressionMethod.Gzip;
}
return constants_1.CompressionMethod.ZstdWithoutLong;
return constants_2.CompressionMethod.ZstdWithoutLong;
}
exports.getCompressionMethod = getCompressionMethod;
function getCacheFileName(compressionMethod) {
return compressionMethod === constants_1.CompressionMethod.Gzip
? constants_1.CacheFilename.Gzip
: constants_1.CacheFilename.Zstd;
switch (compressionMethod) {
case constants_2.CompressionMethod.Gzip:
return constants_2.CacheFilename.Gzip;
case constants_2.CompressionMethod.Lz4:
return constants_2.CacheFilename.Lz4;
default:
return constants_2.CacheFilename.Zstd;
}
}
exports.getCacheFileName = getCacheFileName;
function getCompressionArgs() {
return core.getInput(constants_1.Inputs.CompressionArgs).trim();
}
exports.getCompressionArgs = getCompressionArgs;
async function getGnuTarPathOnWindows() {
if (fs.existsSync(constants_1.GnuTarPathOnWindows)) {
return constants_1.GnuTarPathOnWindows;
if (fs.existsSync(constants_2.GnuTarPathOnWindows)) {
return constants_2.GnuTarPathOnWindows;
}
const versionOutput = await getVersion('tar');
return versionOutput.toLowerCase().includes('gnu tar') ? io.which('tar') : '';
Expand Down
4 changes: 3 additions & 1 deletion dist/cache/constants.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
export declare enum CacheFilename {
Gzip = "cache.tgz",
Zstd = "cache.tzst"
Zstd = "cache.tzst",
Lz4 = "cache.tlz4"
}
export declare enum CompressionMethod {
Gzip = "gzip",
Lz4 = "lz4",
ZstdWithoutLong = "zstd-without-long",
Zstd = "zstd"
}
Expand Down
Loading