Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist/
lib/
node_modules/
26 changes: 26 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -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,
},
};
34 changes: 34 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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)
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,3 @@ node_modules

!dist
!dist/cache

3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist/
lib/
node_modules/
11 changes: 11 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "avoid",
"parser": "typescript"
}
116 changes: 116 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# setup-webapp

A [Github Action](https://help.github.com/en/actions) to enable **multi-layer cache** and **command shorthands** for any workflow. Mostly useful for webapps where frontend and backend services need to be built separately.

Using predefined shortcuts and cache layers, you can split workflows and manage caches with minimal redudant code.

## Example

A simple Python app that setups pip cache and npm cache at the same time:

```yaml
jobs:
cypress:
name: Cypress
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Install dependencies
uses: ktmud/setup-webapp@v1
with:
parallel: true
run: |
npm-install
npm-build

pip-install
python ./bin/manager.py fill_test_data
```

Here, the predefined `npm-install`, `npm-build` and `pip-install` commands will automatically manage `npm` and `pip` cache for you. They are also running in parallel by `node` child processes, so things can be even faster.

Of course, you can customize these commands or add new ones. Simply edit `.github/workflows/bashlib.sh` and override these commands:

```bash
pip-install() {
cd $GITHUB_WORKSPACE

cache-restore pip
pip install -r requirements*.txt

# install additional packages
pip install -e ".[postgres,mysql]"

cache-save pip
}

npm-install() {
echo "npm: $(npm --version)"
echo "node: $(node --version)"

# use a subfolder for the frontend code
cd $GITHUB_WORKSPACE/client/

cache-restore npm
npm ci
cache-save npm
}
```

The `cache-restore` and `cache-save` uses [actions/cache](https://github.com/actions/cache) to manage caches. `npm` and `pip` are two predefined cache layers with following configs:

```js
{
pip: {
path: ['~/.pip'],
hashFiles: ['requirements*.txt'],
keyPrefix: 'pip-',
restoreKeys: 'pip-',
},
npm: {
path: ['~/.npm'],
hashFiles: ['package-lock.json'],
keyPrefix: 'npm-',
restoreKeys: 'npm-',
},
}
```

You can override these by editing `.github/workflows/caches.js`.

### Use different config location

Both the two config files above can be placed in other locations:

```yaml
- uses: ktmud/setup-webapp@v1
with:
run: |
npm-install
npm-build
pip-install
```

### Run commands in parallel

When `parallel` is set to `true`, the `run` inputs will be split into an array of commands and passed to `Promise.all(...)` to execute in parallel.

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/setup-webapp@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
```
79 changes: 79 additions & 0 deletions __tests__/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import path from 'path';
import * as cache from '../src/cache';
import * as inputsUtils from '../src/utils/inputs';
import { setInputs } from '../src/utils/inputs';
import { InputName, GitHubEvent, EnvVariable } from '../src/constants';
import caches, { npmHashFiles, npmExpectedHash } from './fixtures/caches';

describe('cache runner', () => {
it('hash files', async () => {
const hash = await cache.hashFiles(npmHashFiles);
expect(hash).toStrictEqual(npmExpectedHash);
});

it('should use default cache config', async () => {
// when caches is empty, will read from `DefaultInputs.Caches`
await cache.loadCustomCacheConfigs();
// but `npm` actually come from `src/cache/caches.ts`
const inputs = await cache.getCacheInputs('npm');
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.RestoreKeys]).toStrictEqual(
caches.npm.restoreKeys.join('\n'),
);
expect(inputs?.[InputName.Key]).toStrictEqual(`npm-${npmExpectedHash}`);
});

it('should apply inputs', 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 import
expect(setInputsMock).toHaveBeenNthCalledWith(1, {
[EnvVariable.GitHubEventName]: '',
});

// after import, before run
expect(setInputsMock).toHaveBeenNthCalledWith(2, {
[EnvVariable.GitHubEventName]: GitHubEvent.PullRequest,
});

// before run
expect(setInputsMock).toHaveBeenNthCalledWith(3, inputs);

// after run
expect(setInputsMock).toHaveBeenNthCalledWith(4, {
[InputName.Key]: '',
[InputName.Path]: '',
[InputName.RestoreKeys]: '',
});

setInputsMock.mockRestore();

// make sure other calls do not generate errors
await cache.run('save', 'npm');
// incomplete arguments
await cache.run();
await cache.run('save');
// bad arguments
await cache.run('save', 'unknown-cache');
await cache.run('unknown-action', 'unknown-cache');
});
});
5 changes: 5 additions & 0 deletions __tests__/fixtures/bashlib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/bash

default-setup-command() {
print-cachescript-path
}
14 changes: 14 additions & 0 deletions __tests__/fixtures/caches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Example cache config.
*/
export const npmHashFiles = ['.*ignore'];
export const npmExpectedHash =
'5a6a7167f53dd2805b5400bbcb8c06fd8f490a8969784cc8df510211ba36d135';

export default {
npm: {
path: ['~/.npm'],
hashFiles: npmHashFiles,
restoreKeys: ['npm-', 'node-npm-'],
},
};
94 changes: 94 additions & 0 deletions __tests__/setup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* 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.Run,
extraBashlib,
);
});

it('should allow inline bash overrides', async () => {
setInputs({
[InputName.Bashlib]: '',
[InputName.Parallel]: 'false',
[InputName.Run]: `
${DefaultInputs.Run}() {
echo "It works!"
exit 202
}
${DefaultInputs.Run}
`,
});
// allow the bash script to run for one test, but override the default
await setup.run();
expect(runCommandMock).toHaveBeenCalledTimes(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', '');
});
});
Loading