├── .github ├── filters.yml └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── action.yml ├── deploy.sh ├── dist ├── exec-child.js └── index.js ├── example ├── .github │ ├── filters.yml │ └── workflows │ │ └── main.yml ├── LambdaFunction1 │ ├── index.js │ ├── package-lock.json │ └── package.json └── LambdaFunction2 │ ├── index.js │ ├── package-lock.json │ └── package.json ├── index.js ├── index.test.js ├── package-lock.json └── package.json /.github/filters.yml: -------------------------------------------------------------------------------- 1 | LambdaFunction1: 2 | - 'example/LambdaFunction1/**/*' 3 | LambdaFunction2: 4 | - 'example/LambdaFunction2/**/*' 5 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | name: Run tests 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | - name: Tests 14 | run: | 15 | npm ci 16 | npm t 17 | - name: Code Climate Coverage Action 18 | uses: paambaati/codeclimate-action@v8.0.0 19 | env: 20 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 21 | with: 22 | coverageCommand: npm run coverage 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Baptiste Lombard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Maintainability](https://api.codeclimate.com/v1/badges/a76164161fd8916e0dd4/maintainability)](https://codeclimate.com/github/blombard/lambda-monorepo/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/a76164161fd8916e0dd4/test_coverage)](https://codeclimate.com/github/blombard/lambda-monorepo/test_coverage) 2 | 3 | # AWS Lambda monorepo 4 | 5 | Deploy your AWS Lambda functions based on the files changed in a mono repo with this [Github Action](https://github.com/features/actions). 6 | 7 | ## Prerequisites 8 | Set you AWS credentials in the [secrets](https://docs.github.com/en/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets) of your repo: 9 | - AWS_ACCESS_KEY_ID 10 | - AWS_SECRET_ACCESS_KEY 11 | - AWS_REGION 12 | 13 | Create a `filters.yml` file and put it in `.github/worklows` : `.github/worklows/filters.yml` 14 | 15 | The structure of the file should be : 16 | ``` 17 | LambdaFunction1: 18 | - 'LambdaFunction1/**/*' 19 | LambdaFunction2: 20 | - 'LambdaFunction2/**/*' 21 | ``` 22 | 23 | ## Example 24 | 25 | ```yml 26 | on: 27 | push: 28 | branches: 29 | - master 30 | 31 | jobs: 32 | deploy: 33 | name: Deploy to AWS Lambda 34 | runs-on: ubuntu-latest 35 | 36 | steps: 37 | - name: Checkout 38 | uses: actions/checkout@v3 39 | 40 | - name: Configure AWS Credentials 41 | uses: aws-actions/configure-aws-credentials@v2 42 | with: 43 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 44 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 45 | aws-region: ${{ secrets.AWS_REGION }} 46 | 47 | - uses: dorny/paths-filter@v2.2.1 48 | id: filter 49 | with: 50 | filters: .github/filters.yml 51 | 52 | - uses: blombard/lambda-monorepo@master 53 | with: 54 | lambda-functions: '${{ toJson(steps.filter.outputs) }}' 55 | zip-params: '*.js *.json src/ node_modules/' 56 | alias-name: 'production' 57 | layer-name: 'MyLayer' 58 | ``` 59 | 60 | ## Inputs 61 | #### lambda-functions 62 | By default should be `'${{ toJson(steps.filter.outputs) }}'`. Update this only if you know what you are doing. 63 | 64 | #### zip-params 65 | Arguments of the command `zip lambda.zip -r $ZIP_PARAMS`. 66 | It is the files who will be uploaded to your Lambda function. 67 | 68 | #### alias-name 69 | A Lambda [alias](https://docs.aws.amazon.com/lambda/latest/dg/configuration-aliases.html) is like a pointer to a specific Lambda function version. This alias will now point to the new version of your function. 70 | 71 | #### layer-name 72 | If your Lambda use a [layer](https://docs.aws.amazon.com/lambda/latest/dg/configuration-layers.html), it will update you function with the latest version of this layer. 73 | 74 | ## Build your own deploy function 75 | 76 | If your deployment script is very specific to your project you can override the default `deploy.sh` script with your own. 77 | For that you'll need a `deploy.sh` file at the root of your project. 78 | 79 | #### **`deploy.sh`** 80 | ```bash 81 | FUNCTION_NAME=$1 82 | PATH_NAME=$2 83 | ZIP_PARAMS=$3 84 | 85 | if [ -n "$PATH_NAME" ]; then cd $PATH_NAME; fi 86 | 87 | zip lambda.zip -r $ZIP_PARAMS 88 | 89 | aws lambda update-function-code --function-name $FUNCTION_NAME --zip-file fileb://lambda.zip 90 | aws lambda update-function-configuration --function-name $FUNCTION_NAME --environment Variables="{`cat .env | xargs | sed 's/ /,/g'`}" 91 | 92 | rm -f lambda.zip 93 | 94 | exit 0 95 | ``` 96 | 97 | ### Note 98 | 99 | Since the AWS CLI is really verbose, if you need to deploy sensitive data (in your env variables for example) you can use : 100 | ```bash 101 | cmd > /dev/null 102 | ``` 103 | if you don't want to display stdout but still keep stderr. 104 | 105 | ## Sources 106 | 107 | This action is based on those Github actions : 108 | - [configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) 109 | - [paths-filter](https://github.com/dorny/paths-filter) 110 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'AWS Lambda Monorepo Deploy' 2 | description: 'Deploy AWS Lambda functions in a mono repo' 3 | author: 'Baptiste Lombard' 4 | branding: 5 | icon: 'cloud' 6 | color: 'orange' 7 | inputs: 8 | lambda-functions: 9 | description: 'Functions to deploy' 10 | required: true 11 | zip-params: 12 | description: 'Files to zip for uploading to AWS' 13 | required: true 14 | alias-name: 15 | description: 'Pointer to a specific Lambda function version' 16 | default: '' 17 | required: false 18 | layer-name: 19 | description: 'Additional code and content' 20 | default: '' 21 | required: false 22 | runs: 23 | using: 'node20' 24 | main: 'dist/index.js' 25 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | FUNCTION_NAME=$1 2 | PATH_NAME=$2 3 | ZIP_PARAMS=$3 4 | ALIAS_NAME=$4 5 | LAYER_NAME=$5 6 | 7 | if [ -n "$PATH_NAME" ]; then cd $PATH_NAME; fi 8 | 9 | if [[ $ZIP_PARAMS == *"node_modules"* ]]; then npm install --only=prod; fi 10 | 11 | zip lambda.zip -r $ZIP_PARAMS 12 | 13 | if [ -n "$LAYER_NAME" ]; then LAYER=$(aws lambda list-layer-versions --layer-name $LAYER_NAME | jq -r .LayerVersions[0].LayerVersionArn); fi 14 | 15 | RETURNED_FUNCTION_NAME=$(aws lambda update-function-code --function-name $FUNCTION_NAME --zip-file fileb://lambda.zip | jq -r .FunctionName) 16 | 17 | if [ -n "$LAYER_NAME" ]; then 18 | aws lambda update-function-configuration --function-name $FUNCTION_NAME --layers $LAYER --environment Variables="{`cat .env | xargs | sed 's/ /,/g'`}" 19 | else 20 | aws lambda update-function-configuration --function-name $FUNCTION_NAME --environment Variables="{`cat .env | xargs | sed 's/ /,/g'`}" 21 | fi 22 | 23 | if [ -n "$ALIAS_NAME" ] 24 | then 25 | VERSION=$(aws lambda publish-version --function-name $FUNCTION_NAME | jq -r .Version) 26 | aws lambda update-alias --function-name $FUNCTION_NAME --name $ALIAS_NAME --function-version $VERSION 27 | if [ -n "$LAYER_NAME" ]; then 28 | aws lambda update-function-configuration --function-name $FUNCTION_NAME --layers $LAYER --environment Variables="{`cat .env | xargs | sed 's/ /,/g'`}" 29 | else 30 | aws lambda update-function-configuration --function-name $FUNCTION_NAME --environment Variables="{`cat .env | xargs | sed 's/ /,/g'`}" 31 | fi 32 | fi 33 | 34 | rm -f lambda.zip 35 | 36 | if [ "$RETURNED_FUNCTION_NAME" = "$FUNCTION_NAME" ]; then 37 | exit 0 38 | else 39 | exit 1 40 | fi 41 | -------------------------------------------------------------------------------- /dist/exec-child.js: -------------------------------------------------------------------------------- 1 | if (require.main !== module) { 2 | throw new Error('This file should not be required'); 3 | } 4 | 5 | var childProcess = require('child_process'); 6 | var fs = require('fs'); 7 | 8 | var paramFilePath = process.argv[2]; 9 | 10 | var serializedParams = fs.readFileSync(paramFilePath, 'utf8'); 11 | var params = JSON.parse(serializedParams); 12 | 13 | var cmd = params.command; 14 | var execOptions = params.execOptions; 15 | var pipe = params.pipe; 16 | var stdoutFile = params.stdoutFile; 17 | var stderrFile = params.stderrFile; 18 | 19 | var c = childProcess.exec(cmd, execOptions, function (err) { 20 | if (!err) { 21 | process.exitCode = 0; 22 | } else if (err.code === undefined) { 23 | process.exitCode = 1; 24 | } else { 25 | process.exitCode = err.code; 26 | } 27 | }); 28 | 29 | var stdoutStream = fs.createWriteStream(stdoutFile); 30 | var stderrStream = fs.createWriteStream(stderrFile); 31 | 32 | c.stdout.pipe(stdoutStream); 33 | c.stderr.pipe(stderrStream); 34 | c.stdout.pipe(process.stdout); 35 | c.stderr.pipe(process.stderr); 36 | 37 | if (pipe) { 38 | c.stdin.end(pipe); 39 | } 40 | -------------------------------------------------------------------------------- /example/.github/filters.yml: -------------------------------------------------------------------------------- 1 | LambdaFunction1: 2 | - 'LambdaFunction1/**/*' 3 | LambdaFunction2: 4 | - 'LambdaFunction2/**/*' 5 | -------------------------------------------------------------------------------- /example/.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Deployment 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | name: Run Unit Tests 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | 14 | - name: Configure AWS Credentials 15 | uses: aws-actions/configure-aws-credentials@v1 16 | with: 17 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 18 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 19 | aws-region: ${{ secrets.AWS_REGION }} 20 | 21 | - uses: dorny/paths-filter@v2.2.1 22 | id: filter 23 | with: 24 | filters: .github/filters.yml 25 | 26 | - uses: blombard/lambda-monorepo@v0.1 27 | with: 28 | lambda-functions: '${{ toJson(steps.filter.outputs) }}' 29 | zip-params: '*.js *.json node_modules/' 30 | -------------------------------------------------------------------------------- /example/LambdaFunction1/index.js: -------------------------------------------------------------------------------- 1 | exports.handler = async () => { 2 | console.log('Hello World 1'); 3 | return { 4 | statusCode: 200, 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /example/LambdaFunction1/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambdafunction1", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1 5 | } 6 | -------------------------------------------------------------------------------- /example/LambdaFunction1/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambdafunction1", 3 | "version": "1.0.0", 4 | "description": "A simple Lambda function template.", 5 | "main": "index.js" 6 | } 7 | -------------------------------------------------------------------------------- /example/LambdaFunction2/index.js: -------------------------------------------------------------------------------- 1 | exports.handler = async () => { 2 | console.log('Hello World 2'); 3 | return { 4 | statusCode: 200, 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /example/LambdaFunction2/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambdafunction2", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1 5 | } 6 | -------------------------------------------------------------------------------- /example/LambdaFunction2/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambdafunction2", 3 | "version": "1.0.0", 4 | "description": "An other Lambda function template.", 5 | "main": "index.js" 6 | } 7 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const fs = require('fs'); 3 | const YAML = require('yaml'); 4 | const shell = require('shelljs'); 5 | 6 | const deployAll = ({ functions, yml, zipParams, alias, layer }) => { 7 | let success = true; 8 | for (const [key, value] of Object.entries(functions)) { 9 | if (value === 'true') { 10 | const { code } = shell.exec(`sh ./deploy.sh "${key}" "${yml[key][0].split('*')[0]}" "${zipParams}" "${alias}" "${layer}"`); 11 | if (code) { 12 | console.error(`Deployment of ${key} failed!`); 13 | success = false; 14 | } 15 | } 16 | } 17 | return success; 18 | }; 19 | 20 | const run = async () => { 21 | try { 22 | const lambdaFunctions = core.getInput('lambda-functions'); 23 | const zipParams = core.getInput('zip-params'); 24 | const alias = core.getInput('alias-name'); 25 | const layer = core.getInput('layer-name'); 26 | const functions = JSON.parse(lambdaFunctions); 27 | const file = fs.readFileSync('./.github/filters.yml', 'utf8'); 28 | const yml = YAML.parse(file); 29 | 30 | const success = deployAll({ functions, yml, zipParams, alias, layer }); 31 | if (!success) throw new Error('An error occured. At least one Lambda could not be deployed.'); 32 | } catch (error) { 33 | core.setFailed(error.message); 34 | } 35 | }; 36 | 37 | if (require.main === module) { 38 | run(); 39 | } 40 | 41 | module.exports = run; 42 | -------------------------------------------------------------------------------- /index.test.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const shelljs = require('shelljs'); 3 | const run = require('./index.js'); 4 | 5 | const inputs = { 6 | 'lambda-functions': '{"LambdaFunction1": "true", "LambdaFunction2": "false"}', 7 | 'zip-params': '*.js *.json node_modules/', 8 | 'alias-name': 'prod', 9 | 'layer-name': '', 10 | }; 11 | 12 | function mockGetInput(requestResponse) { 13 | return function (name, options) { // eslint-disable-line no-unused-vars 14 | return requestResponse[name]; 15 | }; 16 | } 17 | 18 | jest.mock('@actions/core'); 19 | jest.mock('shelljs'); 20 | 21 | describe('Run the test suite', () => { 22 | test('it should be a success when the params are good', async () => { 23 | core.getInput = jest.fn().mockImplementation(mockGetInput(inputs)); 24 | shelljs.exec = jest.fn().mockImplementation(() => ({ code: 0 })); 25 | await run(); 26 | expect(core.setFailed).not.toHaveBeenCalled(); 27 | }); 28 | test('it should be a failure the deployment script failed', async () => { 29 | shelljs.exec = jest.fn().mockImplementation(() => ({ code: 1 })); 30 | await run(); 31 | expect(core.setFailed).toHaveBeenCalled(); 32 | }); 33 | test('it should be a failure when no params are given', async () => { 34 | core.getInput.mockReset(); 35 | await run(); 36 | expect(core.setFailed).toHaveBeenCalled(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambda-monorepo", 3 | "version": "0.7.0", 4 | "description": "Deploy your AWS Lambda functions based on the files changed in a mono repo", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "coverage": "jest --coverage", 8 | "test": "jest --coverage --verbose", 9 | "build": "ncc build index.js" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/blombard/lambda-monorepo.git" 14 | }, 15 | "author": "Baptiste Lombard", 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/blombard/lambda-monorepo/issues" 19 | }, 20 | "homepage": "https://github.com/blombard/lambda-monorepo#readme", 21 | "dependencies": { 22 | "@actions/core": "^1.10.1", 23 | "shelljs": "^0.8.5", 24 | "yaml": "^2.4.5" 25 | }, 26 | "devDependencies": { 27 | "@vercel/ncc": "^0.36.1", 28 | "jest": "^29.7.0" 29 | } 30 | } 31 | --------------------------------------------------------------------------------