build: inline external Github Actions to unblock CI (#12241)

* build: inline cached-dependencies to unblock CI

* Run E2E on pull_request on;y

* Inline all external actions

* Checkout needed for internal actions

Also fixes pre-commit

* Add missing files
This commit is contained in:
Jesse Yang
2021-01-04 04:16:07 -08:00
committed by GitHub
parent 7cc0de1694
commit a3bbbf8ea3
173 changed files with 48871 additions and 78 deletions

View File

@@ -0,0 +1 @@
indent_size = 2

View File

@@ -0,0 +1,3 @@
dist/
lib/
node_modules/

View File

@@ -0,0 +1,26 @@
module.exports = {
plugins: ['jest', '@typescript-eslint'],
extends: ['plugin:jest/all'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 9,
sourceType: 'module',
},
rules: {
'eslint-comments/no-use': 'off',
'import/no-namespace': 'off',
'no-unused-vars': 'off',
'no-console': 'off',
'jest/prefer-expect-assertions': 'off',
'jest/no-disabled-tests': 'warn',
'jest/no-focused-tests': 'error',
'jest/no-identical-title': 'error',
'jest/prefer-to-have-length': 'warn',
'jest/valid-expect': 'error',
},
env: {
node: true,
es6: true,
'jest/globals': true,
},
};

View File

@@ -0,0 +1,34 @@
name: Tests
on:
pull_request:
paths-ignore:
- '**.md'
push:
branches:
- master
paths-ignore:
- '**.md'
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, macOS-latest]
name: Test on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
node-version: '12.x'
- name: Install dependencies
run: npm ci
- name: Run prettier format check
run: npm run format-check
- name: Build
run: npm run build
- name: Run tests
run: npm run test
- name: Upload code coverage
run: |
bash <(curl -s https://codecov.io/bash)

View File

@@ -0,0 +1,6 @@
lib
coverage
node_modules
!dist
!dist/cache

View File

@@ -0,0 +1,3 @@
dist/
lib/
node_modules/

View File

@@ -0,0 +1,11 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "avoid",
"parser": "typescript"
}

View File

@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2018 GitHub, Inc. and contributors
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.

View File

@@ -0,0 +1,212 @@
# cached-dependencies
[![](https://github.com/ktmud/cached-dependencies/workflows/Tests/badge.svg)](https://github.com/ktmud/cached-dependencies/actions?query=workflow%3ATests) [![codecov](https://codecov.io/gh/ktmud/cached-dependencies/branch/master/graph/badge.svg)](https://codecov.io/gh/ktmud/cached-dependencies)
Enable **multi-layer cache** and **shortcut commands** in any workflows.
Manage multiple cache targets in one step. Use either the built-in cache configs for npm, yarn, and pip, or write your own. Create a bash command library to easily reduce redudencies across workflows. Most useful for building webapps that require multi-stage building processes.
This is your all-in-one action for everything related to setting up dependencies with cache.
## Inputs
- **run**: bash commands to run, allows shortcut commands
- **caches**: path to a JS module that defines cache targets, defaults to `.github/workflows/caches.js`
- **bashlib**: path to a BASH scripts that defines shortcut commands, defaults to `.github/workflows/bashlib.sh`
- **parallel**: whether to run the commands in parallel with node subprocesses
## Examples
Following workflow sets up dependencies for a typical Python web app with both `~/.pip` and `~/.npm` cache configured in one simple step:
```yaml
jobs:
build_and_test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Install dependencies
uses: ktmud/cached-dependencies@v1
with:
run: |
npm-install
npm run build
pip-install
python ./bin/manager.py fill_test_data
```
Here we used predefined `npm-install` and `pip-install` commands to install dependencies with correponding caches.
You may also replace `npm-install` with `yarn-install` to install npm pacakges with `yarn.lock`.
```yaml
- name: Install dependencies
uses: ktmud/cached-dependencies@v1
with:
run: |
yarn-install
yarn build
pip-install
python ./bin/manager.py fill_test_data
```
See below for more details.
## Usage
### Cache configs
Under the hood, we use [@actions/cache](https://github.com/marketplace/actions/cache) to manage cache storage. But instead of defining only one cache at a time and specify them in workflow YAMLs, you manage all caches in a spearate JS file: `.github/workflows/caches.js`.
Here is [the default configuration](https://github.com/ktmud/cached-dependencies/blob/master/src/cache/caches.ts) for Linux:
```js
module.exports = {
pip: {
path: [`${process.env.HOME}/.cache/pip`],
hashFiles: ['requirements*.txt'],
keyPrefix: 'pip-',
restoreKeys: 'pip-',
},
npm: {
path: [`${HOME}/.npm`],
hashFiles: [
`package-lock.json`,
`*/*/package-lock.json`,
`!node_modules/*/package-lock.json`,
],
},
yarn: {
path: [`${HOME}/.npm`],
// */* is for supporting lerna monorepo with depth=2
hashFiles: [`yarn.lock`, `*/*/yarn.lock`, `!node_modules/*/yarn.lock`],
},
}
```
In which `hashFiles` and `keyPrefix` will be used to compute the primary cache key used in [@actions/cache](https://github.com/marketplace/actions/cache). `keyPrefix` will default to `${cacheName}-` and `restoreKeys` will default to `keyPrefix` if not specified.
It is recommended to always use absolute paths in these configs so you can share them across different worflows more easily (in case you the action is called from different working directories).
#### Speficy when to restore and save
With the predefined `cache-store` and `cache-save` bash commands, you have full flexibility on when to restore and save cache:
```yaml
steps:
- uses: actions/checkout@v2
- uses: ktmud/cached-dependencies@v1
with:
run: |
cache-restore npm
npm install
cache-save npm
cache-restore pip
pip install -r requirements.txt
cache-save pip
```
### Shortcut commands
All predefined shortcut commands can be found [here](https://github.com/ktmud/cached-dependencies/blob/master/src/scripts/bashlib.sh). You can also customize them or add new ones in `.github/workflows/bashlib.sh`.
For example, if you want to install additional packages for before saving `pip` cache, simply add this to the `bashlib.sh` file:
```bash
# override the default `pip-install` command
pip-install() {
cd $GITHUB_WORKSPACE
cache-restore pip
echo "::group::pip install"
pip install -r requirements.txt # prod requirements
pip install -r requirements-dev.txt # dev requirements
pip install -e ".[postgres,mysql]" # current pacakge with some extras
echo "::endgroup::"
cache-save pip
}
```
### Default setup command
When `run` is not provided:
```yaml
jobs:
name: Build
steps:
- name: Install dependencies
uses: ktmud/cached-depdencies@v1
```
You must provide a `default-setup-command` in the bashlib. For example,
```bash
default-setup-command() {
pip-install & npm-install
}
```
This will start installing pip and npm dependencies at the same time.
### Customize config locations
Both the two config files, `.github/workflows/bashlib.sh` and `.github/workflows/caches.js`, can be placed in other locations:
```yaml
- uses: ktmud/cached-dependencies@v1
with:
caches: ${{ github.workspace }}/.github/configs/caches.js
bashlib: ${{ github.workspace }}/.github/configs/bashlib.sh
```
### Run commands in parallel
When `parallel` is set to `true`, the `run` input will be split into an array of commands and passed to `Promise.all(...)` to execute in parallel. For example,
```yaml
- uses: ktmud/cached-dependencies@v1
with:
parallel: true
run: |
pip-install
npm-install
```
is equivalent to
```yaml
- uses: ktmud/cached-dependencies@v1
with:
run: |
pip-install & npm-install
```
If one or more of your commands must spread across multiple lines, you can add a new line between the parallel commands. Each command within a parallel group will still run sequentially.
```yaml
- uses: ktmud/cached-dependencies@v1
with:
run: |
cache-restore pip
pip install requirements*.txt
# additional pip packages
pip install package1 package2 pacakge2
cache-save pip
npm-install
cache-restore cypress
cd cypress/ && npm install
cache-save cypress
```
## License
This project is released under [the MIT License](LICENSE).

View File

@@ -0,0 +1,124 @@
import path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import * as core from '@actions/core';
import * as cache from '../src/cache';
import * as inputsUtils from '../src/utils/inputs';
import * as actionUtils from '@actions/cache/src/utils/actionUtils';
import defaultCaches from '../src/cache/caches';
import { setInputs, getInput, maybeArrayToString } from '../src/utils/inputs';
import { Inputs, InputName, GitHubEvent, EnvVariable } from '../src/constants';
import caches, { npmExpectedHash } from './fixtures/caches';
describe('patch core states', () => {
it('should log error if states file invalid', () => {
const logWarningMock = jest.spyOn(actionUtils, 'logWarning');
fs.writeFileSync(`${os.tmpdir()}/cached--states.json`, 'INVALID_JSON', {
encoding: 'utf-8',
});
core.getState('haha');
expect(logWarningMock).toHaveBeenCalledTimes(2);
});
it('should persist state', () => {
core.saveState('test', '100');
expect(core.getState('test')).toStrictEqual('100');
});
});
describe('cache runner', () => {
it('should use default cache config', async () => {
await cache.loadCustomCacheConfigs();
// but `npm` actually come from `src/cache/caches.ts`
const inputs = await cache.getCacheInputs('npm');
expect(inputs?.[InputName.Path]).toStrictEqual(
maybeArrayToString(defaultCaches.npm.path),
);
expect(inputs?.[InputName.RestoreKeys]).toStrictEqual('npm-');
});
it('should override cache config', async () => {
setInputs({
[InputName.Caches]: path.resolve(__dirname, 'fixtures/caches'),
});
await cache.loadCustomCacheConfigs();
const inputs = await cache.getCacheInputs('npm');
expect(inputs?.[InputName.Path]).toStrictEqual(
maybeArrayToString(caches.npm.path),
);
expect(inputs?.[InputName.Key]).toStrictEqual(`npm-${npmExpectedHash}`);
expect(inputs?.[InputName.RestoreKeys]).toStrictEqual(
maybeArrayToString(caches.npm.restoreKeys),
);
});
it('should apply inputs and restore cache', async () => {
setInputs({
[InputName.Caches]: path.resolve(__dirname, 'fixtures/caches'),
[EnvVariable.GitHubEventName]: GitHubEvent.PullRequest,
});
const setInputsMock = jest.spyOn(inputsUtils, 'setInputs');
const inputs = await cache.getCacheInputs('npm');
const result = await cache.run('restore', 'npm');
expect(result).toBeUndefined();
// before run
expect(setInputsMock).toHaveBeenNthCalledWith(1, inputs);
// after run
expect(setInputsMock).toHaveBeenNthCalledWith(2, {
[InputName.Key]: '',
[InputName.Path]: '',
[InputName.RestoreKeys]: '',
});
// inputs actually restored to original value
expect(getInput(InputName.Key)).toStrictEqual('');
// pretend still in execution context
setInputs(inputs as Inputs);
// `core.getState` should return the primary key
expect(core.getState('CACHE_KEY')).toStrictEqual(inputs?.[InputName.Key]);
setInputsMock.mockRestore();
});
it('should run saveCache', async () => {
// call to save should also work
const logWarningMock = jest.spyOn(actionUtils, 'logWarning');
setInputs({
[InputName.Parallel]: 'true',
});
await cache.run('save', 'npm');
expect(logWarningMock).toHaveBeenCalledWith(
'Cache Service Url not found, unable to restore cache.',
);
});
it('should exit on invalid args', async () => {
// other calls do generate errors
const processExitMock = jest
.spyOn(process, 'exit')
// @ts-ignore
.mockImplementation(() => {});
// incomplete arguments
await cache.run();
await cache.run('save');
// bad arguments
await cache.run('save', 'unknown-cache');
await cache.run('unknown-action', 'unknown-cache');
setInputs({
[InputName.Caches]: 'non-existent',
});
await cache.run('save', 'npm');
expect(processExitMock).toHaveBeenCalledTimes(5);
});
});

View File

@@ -0,0 +1,5 @@
#!/bin/bash
default-setup-command() {
print-cachescript-path
}

View File

@@ -0,0 +1,14 @@
/**
* Example cache config.
*/
export const npmHashFiles = ['.*ignore'];
export const npmExpectedHash =
'13ed29a1c7ec906e7dcb20626957ebfcd3f0f2174bd2685a012105792bf1ff55';
export default {
npm: {
path: [`~/.npm`],
hashFiles: npmHashFiles,
restoreKeys: 'node-npm-',
},
};

View File

@@ -0,0 +1,101 @@
/**
* Test default runner.
*/
import { setInputs } from '../src/utils/inputs';
import { InputName, DefaultInputs } from '../src/constants';
import * as setup from '../src/setup';
import path from 'path';
const extraBashlib = path.resolve(__dirname, './fixtures/bashlib.sh');
describe('setup runner', () => {
// don't actually run the bash script
const runCommandMock = jest.spyOn(setup, 'runCommand');
it('should allow custom bashlib', async () => {
setInputs({
[InputName.Bashlib]: extraBashlib,
});
await setup.run();
expect(runCommandMock).toHaveBeenCalledTimes(1);
expect(runCommandMock).toHaveBeenCalledWith(
DefaultInputs[InputName.Run],
extraBashlib,
);
});
it('should allow inline bash overrides', async () => {
const processExitMock = jest
.spyOn(process, 'exit')
// @ts-ignore
.mockImplementation(() => {});
setInputs({
[InputName.Bashlib]: '',
[InputName.Parallel]: 'false',
[InputName.Run]: `
${DefaultInputs[InputName.Run]}() {
echo "It works!"
exit 202
}
${DefaultInputs[InputName.Run]}
`,
});
// allow the bash script to run for one test, but override the default
await setup.run();
expect(runCommandMock).toHaveBeenCalledTimes(1);
expect(processExitMock).toHaveBeenCalledTimes(1);
expect(processExitMock).toHaveBeenCalledWith(1);
});
it('should use run commands', async () => {
// don't run the commands when there is no overrides
runCommandMock.mockImplementation(async () => {});
setInputs({
[InputName.Bashlib]: 'non-existent',
[InputName.Run]: 'print-cachescript-path',
});
await setup.run();
expect(runCommandMock).toHaveBeenCalledTimes(1);
expect(runCommandMock).toHaveBeenCalledWith('print-cachescript-path', '');
});
it('should handle single-new-line parallel commands', async () => {
setInputs({
[InputName.Run]: `
test-command-1
test-command-2
`,
[InputName.Parallel]: 'true',
});
await setup.run();
expect(runCommandMock).toHaveBeenNthCalledWith(1, 'test-command-1', '');
expect(runCommandMock).toHaveBeenNthCalledWith(2, 'test-command-2', '');
});
it('should handle multi-new-line parallel commands', async () => {
setInputs({
[InputName.Run]: `
test-1-1
test-1-2
test-2
`,
[InputName.Parallel]: 'true',
});
await setup.run();
expect(runCommandMock).toHaveBeenNthCalledWith(
1,
'test-1-1\n test-1-2',
'',
);
expect(runCommandMock).toHaveBeenNthCalledWith(2, 'test-2', '');
});
});

View File

@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": "./",
"outDir": "../build",
"noEmit": true,
"rootDir": "../"
},
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,25 @@
name: Cached Dependencies
description: Setup multi-layered cache and dependencies in one step, share predefined commands across workflows
author: Jesse Yang <hello@yjc.me>
branding:
icon: layers
color: yellow
inputs:
caches:
required: false
description: Path to a JS file with cache configs
default: ${{ github.workspace }}/.github/workflows/caches.js
bashlib:
required: false
description: Path to a Bash script with command shortcuts
default: ${{ github.workspace }}/.github/workflows/bashlib.sh
run:
required: false
description: Setup commands to run, can use shortcuts defined in bashlib
default: default-setup-command
parallel:
required: false
description: Whether to run commands in parallel
runs:
using: node12
main: dist/index.js

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
'use strict';
const fs = require('fs');
const crypto = require('crypto');
const {parentPort} = require('worker_threads');
const handlers = {
hashFile: (algorithm, filePath) => new Promise((resolve, reject) => {
const hasher = crypto.createHash(algorithm);
fs.createReadStream(filePath)
// TODO: Use `Stream.pipeline` when targeting Node.js 12.
.on('error', reject)
.pipe(hasher)
.on('error', reject)
.on('finish', () => {
const {buffer} = hasher.read();
resolve({value: buffer, transferList: [buffer]});
});
}),
hash: async (algorithm, input) => {
const hasher = crypto.createHash(algorithm);
if (Array.isArray(input)) {
for (const part of input) {
hasher.update(part);
}
} else {
hasher.update(input);
}
const hash = hasher.digest().buffer;
return {value: hash, transferList: [hash]};
}
};
parentPort.on('message', async message => {
try {
const {method, args} = message;
const handler = handlers[method];
if (handler === undefined) {
throw new Error(`Unknown method '${method}'`);
}
const {value, transferList} = await handler(...args);
parentPort.postMessage({id: message.id, value}, transferList);
} catch (error) {
const newError = {message: error.message, stack: error.stack};
for (const [key, value] of Object.entries(error)) {
if (typeof value !== 'object') {
newError[key] = value;
}
}
parentPort.postMessage({id: message.id, error: newError});
}
});

View File

@@ -0,0 +1,21 @@
module.exports = {
clearMocks: true,
moduleFileExtensions: ['js', 'ts'],
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
transformIgnorePatterns: [
'/node_modules/(?!@actions).+\\.js$',
],
verbose: true,
};
// suppress debug messages
const processStdoutWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = (str, encoding, cb) => {
processStdoutWrite(str.split('\n').filter(x => {
return !/^::debug::/.test(x);
}).join('\n'), encoding, cb);
};

8197
.github/actions/cached-dependencies/package-lock.json generated vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
{
"name": "setup-superset-action",
"version": "1.0.0",
"private": true,
"keywords": [
"actions",
"node",
"setup",
"superset"
],
"main": "dist/run",
"scripts": {
"all": "npm run format && npm run lint && npm run test && npm run build",
"build": "npm run clean && tsc && ncc build -o dist src/run.ts && ncc build -o dist/scripts/cache src/scripts/cache.ts",
"clean": "rm -rf ./lib ./dist",
"coverage": "npm run test && open ./coverage/lcov-report/index.html",
"format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts",
"lint": "eslint src/**/*.ts",
"test": "jest --clearCache && jest --coverage"
},
"dependencies": {
"@actions/cache": "actions/cache#d29c1df198dd38ac88e0ae23a2881b99c2d20e68",
"@actions/core": "1.2.4",
"@actions/exec": "1.0.4",
"@actions/glob": "0.1.0",
"@types/uuid": "7.0.4",
"hasha": "5.2.0",
"tempy": "0.6.0",
"uuid": "7.0.3"
},
"devDependencies": {
"@types/jest": "26.0.7",
"@types/node": "12.12.53",
"@typescript-eslint/eslint-plugin": "3.7.1",
"@typescript-eslint/parser": "3.7.1",
"@zeit/ncc": "0.22.3",
"eslint": "7.5.0",
"eslint-plugin-jest": "23.19.0",
"jest": "26.1.0",
"js-yaml": "3.14.0",
"prettier": "2.0.5",
"prettier-plugin-packagejson": "2.2.5",
"ts-jest": "26.1.4",
"typescript": "3.9.7"
}
}

View File

@@ -0,0 +1,5 @@
{
"extends": [
"config:base"
]
}

View File

@@ -0,0 +1,49 @@
/**
* Default cache configs
*/
import * as os from 'os';
export interface CacheConfig {
path: string[] | string;
hashFiles: string[] | string;
keyPrefix?: string;
restoreKeys?: string[] | string;
}
export interface CacheConfigs {
[cacheName: string]: CacheConfig;
}
const { HOME = '~' } = process.env;
const platform = os.platform() as 'linux' | 'darwin' | 'win32';
const pathByPlatform = {
linux: {
pip: `${HOME}/.cache/pip`,
},
darwin: {
pip: `${HOME}/Library/Caches/pip`,
},
win32: {
pip: `${HOME}\\AppData\\Local\\pip\\Cache`,
},
};
export default {
pip: {
path: pathByPlatform[platform].pip,
hashFiles: 'requirements*.txt',
},
npm: {
path: `${HOME}/.npm`,
hashFiles: [
`package-lock.json`,
// support lerna monorepo with depth=2
`*/*/package-lock.json`,
`!node_modules/*/package-lock.json`,
],
},
yarn: {
path: `${HOME}/.npm`,
hashFiles: [`yarn.lock`, `*/*/yarn.lock`, `!node_modules/*/yarn.lock`],
},
} as CacheConfigs;

View File

@@ -0,0 +1,146 @@
/**
* Execute @actions/cache with predefined cache configs.
*/
import { beginImport, doneImport } from './patch'; // monkey patch @actions modules
beginImport();
import saveCache from '@actions/cache/src/save';
import restoreCache from '@actions/cache/src/restore';
doneImport();
import hasha from 'hasha';
import * as fs from 'fs';
import * as core from '@actions/core';
import * as glob from '@actions/glob';
import { Inputs, InputName, DefaultInputs } from '../constants';
import { applyInputs, getInput, maybeArrayToString } from '../utils/inputs';
import caches from './caches'; // default cache configs
// GitHub uses `sha256` for the built-in `${{ hashFiles(...) }}` expression
// https://help.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#hashfiles
const HASH_OPTION = { algorithm: 'sha256' };
/**
* Load custom cache configs from the `caches` path defined in inputs.
*
* @returns Whether the loading is successfull.
*/
export async function loadCustomCacheConfigs() {
const customCachePath = getInput(InputName.Caches);
try {
core.debug(`Reading cache configs from '${customCachePath}'`);
const customCache = await import(customCachePath);
Object.assign(caches, customCache.default);
} catch (error) {
if (
customCachePath !== DefaultInputs[InputName.Caches] ||
!error.message.includes('Cannot find module')
) {
core.error(error.message);
core.setFailed(
`Failed to load custom cache configs: '${customCachePath}'`,
);
return process.exit(1);
}
}
return true;
}
/**
* Generate SHA256 hash for a list of files matched by glob patterns.
*
* @param {string[]} patterns - The glob pattern.
* @param {string} extra - The extra string to append to the file hashes to
* comptue the final hash.
*/
export async function hashFiles(
patterns: string[] | string,
extra: string = '',
) {
const globber = await glob.create(maybeArrayToString(patterns));
let hash = '';
let counter = 0;
for await (const file of globber.globGenerator()) {
if (!fs.statSync(file).isDirectory()) {
hash += hasha.fromFileSync(file, HASH_OPTION);
counter += 1;
}
}
core.debug(`Computed hash for ${counter} files. Pattern: ${patterns}`);
return hasha(hash + extra, HASH_OPTION);
}
/**
* Generate GitHub Action inputs based on predefined cache config. Will be used
* to override env variables.
*
* @param {string} cacheName - Name of the predefined cache config.
*/
export async function getCacheInputs(
cacheName: string,
): Promise<Inputs | null> {
if (!(cacheName in caches)) {
return null;
}
const { keyPrefix, restoreKeys, path, hashFiles: patterns } = caches[
cacheName
];
const pathString = maybeArrayToString(path);
const prefix = keyPrefix || `${cacheName}-`;
// include `path` to hash, too, so to burse caches in case users change
// the path definition.
const hash = await hashFiles(patterns, pathString);
return {
[InputName.Key]: `${prefix}${hash}`,
[InputName.Path]: pathString,
// only use prefix as restore key if it is never defined
[InputName.RestoreKeys]:
restoreKeys === undefined ? prefix : maybeArrayToString(restoreKeys),
};
}
export const actions = {
restore(inputs: Inputs) {
return applyInputs(inputs, restoreCache);
},
save(inputs: Inputs) {
return applyInputs(inputs, saveCache);
},
};
export type ActionChoice = keyof typeof actions;
export async function run(
action: string | undefined = undefined,
cacheName: string | undefined = undefined,
) {
if (!action || !(action in actions)) {
core.setFailed(`Choose a cache action from: [restore, save]`);
return process.exit(1);
}
if (!cacheName) {
core.setFailed(`Must provide a cache name.`);
return process.exit(1);
}
const runInParallel = getInput(InputName.Parallel);
if (await loadCustomCacheConfigs()) {
if (runInParallel) {
core.info(`${action.toUpperCase()} cache for ${cacheName}`);
} else {
core.startGroup(`${action.toUpperCase()} cache for ${cacheName}`);
}
const inputs = await getCacheInputs(cacheName);
if (inputs) {
core.info(JSON.stringify(inputs, null, 2));
await actions[action as ActionChoice](inputs);
} else {
core.setFailed(`Cache '${cacheName}' not defined, failed to ${action}.`);
return process.exit(1);
}
if (!runInParallel) {
core.endGroup();
}
}
}

View File

@@ -0,0 +1,95 @@
/**
* Monkey patch to safely import and use @action/cache modules
*/
import * as utils from '@actions/cache/src/utils/actionUtils';
import * as core from '@actions/core';
import * as fs from 'fs';
import * as os from 'os';
import { InputName } from '../constants';
import { getInput } from '../utils/inputs';
interface KeyValueStore {
[key: string]: any;
}
const { logWarning, isValidEvent } = utils;
const { getState, saveState } = core;
function getStateStoreFile() {
const cacheName = getInput(InputName.Key);
return `${os.tmpdir()}/cached-${cacheName}-states.json`;
}
/**
* Load states from the persistent store.
*
* The default `core.saveState` only writes states as command output, and
* `core.getState` is only possible to read the state in a later step via ENV
* variables.
*
* So we use a temp file to save and load states, so to allow persistent
* states within the same step.
*
* Since the state output is not uniq to caches, each cache should have their
* own file for persistent states.
*/
function loadStates() {
const stateStore = getStateStoreFile();
const states: KeyValueStore = {};
try {
Object.assign(
states,
JSON.parse(fs.readFileSync(stateStore, { encoding: 'utf-8' })),
);
core.debug(`Loaded states from: ${stateStore}`)
} catch (error) {
// pass
if (error.code !== 'ENOENT') {
utils.logWarning(`Could not load states: ${stateStore}`)
utils.logWarning(error.message);
}
}
return states;
}
/**
* Save states to the persistent storage.
*/
function persistState(name: string, value: any) {
const states = loadStates();
const stateStore = getStateStoreFile();
const valueString = typeof value === 'string' ? value : JSON.stringify(value);
// make sure value is always string
states[name] = valueString;
// persist state in the temp file
fs.writeFileSync(stateStore, JSON.stringify(states, null, 2), {
encoding: 'utf-8',
});
core.debug(`Persist state "${name}=${valueString}" to ${stateStore}`);
// still pass the original value to the original function, though
return saveState(name, value);
}
/**
* Get states from persistent store, fallback to "official" states.
*/
function obtainState(name: string) {
const states = loadStates();
return states[name] || getState(name);
}
export function beginImport() {
Object.defineProperty(utils, 'isValidEvent', { value: () => false });
Object.defineProperty(utils, 'logWarning', { value: () => {} });
}
export function doneImport() {
Object.defineProperty(utils, 'isValidEvent', { value: isValidEvent });
Object.defineProperty(utils, 'logWarning', { value: logWarning });
Object.defineProperty(core, 'saveState', { value: persistState });
Object.defineProperty(core, 'getState', { value: obtainState });
}

View File

@@ -0,0 +1,43 @@
// Possible input names
export enum InputName {
// @actions/cache specific inputs
Key = 'key',
Path = 'path',
RestoreKeys = 'restore-keys',
// setup-webapp specific inputs
Run = 'run',
Caches = 'caches',
Bashlib = 'bashlib',
Parallel = 'parallel',
}
// Possible GitHub event names
export enum GitHubEvent {
Push = 'push',
PullRequest = 'pull_request',
}
// Directly available environment variables
export enum EnvVariable {
GitHubEventName = 'GITHUB_EVENT_NAME',
}
export const EnvVariableNames = new Set(Object.values(EnvVariable) as string[]);
export interface Inputs {
[EnvVariable.GitHubEventName]?: string;
[InputName.Key]?: string;
[InputName.RestoreKeys]?: string;
[InputName.Path]?: string;
[InputName.Caches]?: string;
[InputName.Bashlib]?: string;
[InputName.Run]?: string;
[InputName.Parallel]?: string;
}
export const DefaultInputs = {
[InputName.Caches]: '.github/workflows/caches.js',
[InputName.Bashlib]: '.github/workflows/bashlib.sh',
[InputName.Run]: 'default-setup-command',
} as Inputs;

View File

@@ -0,0 +1,3 @@
import { run } from './setup';
run();

View File

@@ -0,0 +1,61 @@
#!/bin/bash
# -----------------------------------------------
# Predefined command shortcuts
# -----------------------------------------------
# Exit on any command fails
set -e
bashSource=${BASH_SOURCE[${#BASH_SOURCE[@]} - 1]:-${(%):-%x}}
cacheScript="$(dirname $(dirname $(dirname $bashSource)))/dist/scripts/cache"
print-cachescript-path() {
echo $cacheScript
}
cache-restore() {
node $cacheScript restore $1
}
cache-save() {
node $cacheScript save $1
}
# install python packages
pip-install() {
cache-restore pip
echo "::group::Install Python pacakges"
pip install -r requirements.txt # install dependencies
pip install -e . # install current directory as editable python package
echo "::endgroup"
cache-save pip
}
# install npm packages
npm-install() {
cache-restore npm
echo "::group::Install npm pacakges"
echo "npm: $(npm --version)"
echo "node: $(node --version)"
npm ci
echo "::endgroup::"
cache-save npm
}
# install npm packages via yarn
yarn-install() {
cache-restore yarn
echo "::group::Install npm pacakges via yarn"
echo "npm: $(npm --version)"
echo "node: $(node --version)"
echo "yarn: $(yarn --version)"
yarn
echo "::endgroup::"
cache-save yarn
}
# default setup will install both pip and npm pacakges at the same time
default-setup-command() {
echo 'Please provide `run` commands or configure `default-setup-command`.'
exit 1
}

View File

@@ -0,0 +1,18 @@
/**
* Runner script to store/save caches by predefined configs.
* Used in `scripts/bashlib.sh`.
*/
import { EnvVariable } from '../constants';
// To import `@actions/cache` modules safely, we must set GitHub event name to
// a invalid value, so actual runner code doesn't execute.
const originalEvent = process.env[EnvVariable.GitHubEventName];
process.env[EnvVariable.GitHubEventName] = 'CACHE_HACK';
import { run } from '../cache';
// then we restore the event name before the job actually runs
process.env[EnvVariable.GitHubEventName] = originalEvent;
// @ts-ignore
run(...process.argv.slice(2));

View File

@@ -0,0 +1,66 @@
/**
* Load inputs and execute.
*/
import * as core from '@actions/core';
import { exec } from '@actions/exec';
import path from 'path';
import fs from 'fs';
import { DefaultInputs, InputName } from './constants';
import { getInput } from './utils/inputs';
const SHARED_BASHLIB = path.resolve(__dirname, '../src/scripts/bashlib.sh');
/**
* Run bash commands with predefined lib functions.
*
* @param {string} cmd - The bash commands to execute.
*/
export async function runCommand(
cmd: string,
extraBashlib: string,
): Promise<void> {
const bashlibCommands = [`source ${SHARED_BASHLIB}`];
if (extraBashlib) {
bashlibCommands.push(`source ${extraBashlib}`);
}
try {
await exec('bash', ['-c', [...bashlibCommands, cmd].join('\n ')]);
} catch (error) {
core.setFailed(error.message);
process.exit(1);
}
}
export async function run(): Promise<void> {
let bashlib = getInput(InputName.Bashlib);
const rawCommands = getInput(InputName.Run);
const runInParallel = getInput(InputName.Parallel);
if (!fs.existsSync(bashlib)) {
if (bashlib !== DefaultInputs[InputName.Bashlib]) {
core.error(`Custom bashlib "${bashlib}" does not exist.`);
}
// don't add bashlib to runCommand
bashlib = '';
}
if (runInParallel) {
// Attempt to split by two or more new lines first, if there is still only
// one command, attempt to split by one new line. This is because users
// asked for parallelization, so we make our best efforts to get multiple
// commands.
let commands = rawCommands.split(/\n{2,}/);
if (commands.length === 1) {
commands = rawCommands.split('\n');
}
core.debug(`>> Run ${commands.length} commands in parallel...`);
await Promise.all(
commands
.map(x => x.trim())
.filter(x => !!x)
.map(cmd => exports.runCommand(cmd, bashlib)),
);
} else if (rawCommands) {
await exports.runCommand(rawCommands, bashlib);
}
}

View File

@@ -0,0 +1,2 @@
declare module '@actions/cache/dist/restore';
declare module '@actions/cache/dist/save';

View File

@@ -0,0 +1,61 @@
/**
* Manage inputs and env variables.
*/
import * as core from '@actions/core';
import {
Inputs,
EnvVariableNames,
InputName,
DefaultInputs,
} from '../constants';
export function getInput(name: keyof Inputs): string {
const value = core.getInput(name);
if (name === InputName.Parallel) {
return value.toUpperCase() === 'TRUE' ? value : '';
}
return value || DefaultInputs[name] || '';
}
/**
* Update env variables associated with some inputs.
* See: https://github.com/actions/toolkit/blob/5b940ebda7e7b86545fe9741903c930bc1191eb0/packages/core/src/core.ts#L69-L77 .
*
* @param {Inputs} inputs - The new inputs to apply to the env variables.
*/
export function setInputs(inputs: Inputs): void {
for (const [name, value] of Object.entries(inputs)) {
const envName = EnvVariableNames.has(name)
? name
: `INPUT_${name.replace(/ /g, '_').toUpperCase()}`;
process.env[envName] = value;
}
}
/**
* Apply new inputs and execute a runner function, restore them when done.
*
* @param {Inputs} inputs - The new inputs to apply to the env variables before
* excuting the runner.
* @param {runner} runner - The runner function that returns a promise.
* @returns {Promise<any>} - The result from the runner function.
*/
export async function applyInputs(
inputs: Inputs,
runner: () => Promise<void>,
): Promise<any> {
const originalInputs: Inputs = Object.fromEntries(
Object.keys(inputs).map(name => [
name,
EnvVariableNames.has(name) ? process.env[name] : core.getInput(name),
]),
);
exports.setInputs(inputs);
const result = await runner();
exports.setInputs(originalInputs);
return result;
}
export function maybeArrayToString(input: string[] | string) {
return Array.isArray(input) ? input.join('\n') : input;
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"lib": ["esnext"],
"moduleResolution": "node",
"outDir": "./lib",
"rootDir": ".",
"strict": true,
"noImplicitAny": true,
"esModuleInterop": true,
"preserveSymlinks": true
},
"include": [
"./src",
"./node_modules/@actions"
],
"exclude": ["**/*.test.ts", "__tests__"]
}