GitHub Actions with Monorepos

GitHub Actions with Monorepos
Photo by Robert V. Ruggiero / Unsplash

In this post we'll look at setting up a Monorepo and integrating it with GitHub Actions.  Nx will be used as our monorepo tool.  We'll use a GitHub Action to deploy an application to Firebase and another Github Action to deploy an API to Firebase.  

Our build pipeline will be split into frontend and backend sections.  Changing only APIs will only trigger the backend actions and changing the app code will only trigger the frontend actions.  Changing both will trigger frontend and backend actions.

A link to the github repository accompanying this post can be found here and in the resources.

Monorepos

First lets address what and why Monorepos exist.

A monorepo is a single repository containing multiple distinct projects, with well-defined relationships.

So if you are working in a team that has multiple distinct projects that are related a monorepo is a good choice, it will make standardizing tooling simpler and reduce inefficiencies related to dealing with multiple repositories.

GitHub Actions

So why would we want to integrate with GitHub Actions.

GitHub Actions makes it easy to automate all your software workflows, now with world-class CI/CD. Build, test, and deploy your code right from GitHub. Make code reviews, branch management, and issue triaging work the way you want.

Creating Our Monorepo

For the purposes of this post we'll use Nx as our monorepo tool.  Nx can be used for frontend and backend projects.  It's plugin system allows developers to choose various tech stacks to work with.  We'll use Angular and Nodejs (Nest will be used as Server Side framework).

Creating our monorepo can be done by running the below command.  

npx create-nx-workspace@latest monorepo-github-actions

I've opted for the angular-nest preset which will setup a fullstack application using Angular for frontend and Nest for the backend.  

You can skip the preset options with below command, if you like.

npx create-nx-workspace@latest monorepo-github-actions --preset=angular-nest

You'll be prompted to give you application a name and to choose your default stylesheet format.

You can also choose to use Nx Cloud at this point.  I would recommend doing so at this point so you can evaluate it and determine if it's going to work for your project.

Firebase Setup

Now to set up Firebase.  First you need to install the Firebase CLI.

Firebase CLI reference | Firebase Documentation

Once Firebase is installed, you can start installing dependencies and initializing the Firebase project.

cd monorepo-github-actions && npm i firebase-functions express @nestjs/platform-express

firebase init

At this point we'll select only the Functions option.  We could also select Hosting but that resulted in an error with my version of firebase CLI (11.4.2).

You'll be prompted to create a new project and select Javascript or Typescript.  Typescript is preferred option here.  Also, opt to use eslint at this point.

Once complete you will have a firebase.json and a .firebaserc file created.  We'll modify these shortly.

firebase init hosting

Update package.json with below:

"main": "dist/apps/api/main.js",
"engines": {
  "node": "16"
}
package.json

A functions directory was created but can now be deleted, as we will update the firebase.json file to point to the current directory instead.

{
  "functions": {
    "source": ".",
    "predeploy": [
      "npm --prefix \"$RESOURCE_DIR\" run build"
    ]
  },
  "hosting": {
    "public": "dist/apps/monorepo-github-actions",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
}
firebase.json

Note that we are saying we want Firebase to deploy our frontend app, which once built will be located in dist/apps/monorepo-github-actions.  You will need to ensure the name matches your app name.

Modifying Nest Server

In order to make our API work with Nest we need to modify the bootstrapping code in apps/api/src/main.ts.  It should look like below:

import * as express from 'express';
import * as functions from 'firebase-functions';

import {ExpressAdapter, NestExpressApplication} from '@nestjs/platform-express';
import {NestFactory} from '@nestjs/core';
import {AppModule} from './app/app.module';

const server: express.Express = express();

export const createNestServer = async (expressInstance: express.Express) => {
  console.log("test action!!")
  const adapter = new ExpressAdapter(expressInstance);
  const app = await NestFactory.create<NestExpressApplication>(
    AppModule, adapter, {},
  );
  app.enableCors();
  return app.init();
};

createNestServer(server)
  .then(v => console.log('Nest Server Ready...'))
  .catch(err => console.error('Nest broken', err));

export const api: functions.HttpsFunction = functions.region('europe-west1').https.onRequest(server);
apps/api/src/main.ts

To verify Nest is working as expected try running it locally:

nx serve api

To verify you can run the app try running it locally:

nx serve monorepo-github-actions

Adding a GitHub Action

A GitHub Action can be added to a project by creating a .github/workflows directory and adding a <my-workflow>.yml file that describes what action you want to trigger and when it should be triggered, e.g. when a push or pull request is performed.  

GitHub actions offer a lot of flexibility to ensure you can execute the workflow that works for your particular projects.  You can run shell scripts, you can define what branches they should be triggered on and you can integrate community created actions that solve common issues, e.g. how to filter on paths and only execute workflows on particular directories (https://github.com/dorny/paths-filter).  This is exactly what we need when working on a monorepo.

We want to trigger builds only if certain paths within our monorepo have changed.  For the purposes of this post we'll separate our builds into frontend and backend.

The workflow below will trigger an action on our API or our application or both, if there is a change committed and pushed to either.  In this way we can ensure actions are only triggered if there is a change to the app or api in our Nx monorepo.

If there are changes to the frontend the action will deploy the changes to Firebase so any reviewers can see the change live.  If the change is to the backend API it will deploy the updated API where reviewers can test the functionality.

name: run-frontend-backend-jobs
on: [push]
jobs:

  # JOB to run change detection
  changes:
    runs-on: ubuntu-latest

    # Set job outputs to values from filter step
    outputs:
      backend: ${{ steps.filter.outputs.backend }}
      frontend: ${{ steps.filter.outputs.frontend }}
    steps:
    - uses: actions/checkout@v3
    - uses: dorny/paths-filter@v2.10.2
      id: filter
      with:
        filters: |
          backend:
            - 'apps/api/**'
          frontend:
            - 'apps/monorepo-github-actions/**'

  # JOB to build and deploy backend code
  backend:
    needs: changes
    if: ${{ needs.changes.outputs.backend == 'true' }}
    runs-on: ubuntu-latest

    steps:
    - name: Checkout Repo
      uses: actions/checkout@master

    - name: Install Dependencies
      run: npm install

    - name: Build API
      run: npm run build api

    - name:  Deploy to Firebase Function
      uses: w9jds/firebase-action@master
      with:
          args: deploy --only functions
      env:
          FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}

  # JOB to build and deploy frontend code
  frontend:
    needs: changes
    if: ${{ needs.changes.outputs.frontend == 'true' }}
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - run: npm ci && npm run build monorepo-github-actions
      - uses: FirebaseExtended/action-hosting-deploy@v0
        with:
          repoToken: '${{ secrets.GITHUB_TOKEN }}'
          firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_MONOREPO_GITHUB_ACTIONS_FRBS }}'
          channelId: live
          projectId: monorepo-github-actions-frbs

.github/workflows/actions.yml

Now we have all the pieces necessary but we don't yet have a github repository for our monorepo.  So create a Github repository and push this code to it to get started.  If you want you can fork the sample repository that accompanies this blog post, it's linked below.

When you push updates to your repository you should see the actions being triggered as shown below.

Verifying Deployed API

Once you push a change to your API you should see a Function URL listed under the backend section on the Actions tab for your Github repository.  It will be under the Deploy to Firebase Fuction section.  By default this URL will give a 403 Forbidden response as security is enabled by default.

There are two options to access you deployed API:

  1. keep it secure and require a token for access
  2. make your API publically available

Lets start with option 1, which is less secure but may be what you need depending on you API.  You should be aware that you may incur costs if your API starts getting used a lot and factor this into your development planning.

To enable access for all users you need to navigate the the Google Cloud console and add permissions for your API:

Go to https://console.cloud.google.com/functions/list

  1. Select your project in toolbar if you have more than one
  2. Select the function you want to enable access on
  3. Click Permissions
  4. Click Add Principal
  5. Type allUsers in New Principals input
  6. Select role > Cloud Functions > Cloud Functions Invoker
  7. Save

Configured permission is shown below.  Now if you navigate to your deployed app you should see the welcome message returned from your API.

Option 2, is a bit more involved but is a better choice when you are starting out and if you need authentication.  Below covers how to successfully call you API using curl.

To get a successful response we need to call the API with a valid Bearer token.  To do this we'll need to install gclould.  See https://cloud.google.com/sdk/docs/install.

Once gcloud is installed we can call our API using gclould to get the token:

curl -m 70 -X GET <function-url-here>/hello -H "Authorization:bearer $(gcloud auth print-identity-token)"

Resources

GitHub - tomeustace/monorepo-github-actions
Contribute to tomeustace/monorepo-github-actions development by creating an account on GitHub.
GitHub - dorny/paths-filter: Conditionally run actions based on files modified by PR, feature branch or pushed commits
Conditionally run actions based on files modified by PR, feature branch or pushed commits - GitHub - dorny/paths-filter: Conditionally run actions based on files modified by PR, feature branch or p...
Monorepo Explained
Everything you need to know about monorepos, and the tools to build them.
Features • GitHub Actions
Easily build, package, release, update, and deploy your project in any language—on GitHub or any external system—without having to run code yourself.
A Perfect Match: NestJs & Cloud Functions (2nd gen) & Nx WorkSpace
Note: The 2nd gen is covered by the Pre-GA Offerings Terms of the Google Cloud Terms of Service. Please avoid using it in producttion util…
Install the gcloud CLI | Google Cloud
GitHub - TriPSs/nx-extend: Nx Workspaces builders and tools
Nx Workspaces builders and tools. Contribute to TriPSs/nx-extend development by creating an account on GitHub.
Automate Firebase cloud functions deployment with Github actions.
Github actions provide a workflow that can help carry out various actions such as build the code in your repository, deploy to your production or staging environment, run tests on your code before carrying out some crucial operations, and so on.