GitHub Actions for Monorepos and easy deployments

GitHub Actions are super useful for automating large parts of the repetivie work that goes into building software. I recently wrote a few workflows that I think might be useful for other folks too.

What I'm aiming for

In general it's a good idea to automate repetitive tasks that you find yourself doing often. Most teams will put together the usual Continuous Integrations greatest hits like automatic unit test / linting and automated deployments aren't all that uncommon either.

I recently found myself working in a number of repositories that each had slightly different mechanisms for deploying code. Similarly version numbers were arbitrarily generted for production builds and relied on engineers grabbing a tag, incrementing and pushing to GitHub to trigger a workflow.

Finally, a lot of engineers are using Conventional Commits now and this unlocks interesting possibilities for versioning and release notes.

Automating prod deployments and release notes for NodeJS

Production deployments mean different things to different teams and pipelines are going to differ between technologies. For most web apps these days, React, Vue, Angular etc there's a good chance a package.json file is being used to organise the project's dependencies and related config.

Building npm packages has always been super easy (just a quick glance into your node_modules will confirm as much) and that means npm comes with a bunch of useful versioning commands. The workflow below uses npm version along with mathieudutour/github-tag-action to take a tag, generated from the semver interpretation of the conventional commit messages.

Versions from conventional commits

Take the following commits:

  • chore: Fixing homepage css
  • feat: adding new login prompt
  • revert!: implementing edit button

Here is an example of the 3 different levels of semver that can be achieved, with the above commits each indicating a patch, minor, and major.

When combined we take the most significant semver (i.e major.minor.patch) which for the above example would be a bump on the major.

While semver is useful for libraries in indicating breaking changes between versions, it's also a useful mechanism to remove the manual steps of bumping version numbers in apps.

The bulk of the heavy lifting is done by the github-tag-action which calculates the magic semver for us. Normally we could push the tag there and then but we want to leave that to the npm version step just below. The other nice thing it does is generate a changelog which collates all the different commit messages into one long string. The changelog will be useful for when we want to generate the release notes.

As mentioned npm version manages our version and in the action it's creating the new tag, updating the package.json and creating a commit which leaves a simple git push to sync the release changes (note that the npm version will create the tag as part of it's invocation so the user.name / user.email needs to be set before).

Once the tags / package bumps have been synchronised we can then generate release notes. Once again the heavy lifting is done by ncipollo/release-action@v1 and takes the changelog produced earlier and creates a release note that is helpfully grouped according to the conventional commits found between the current and previous tags.

You can check out the workflow below:

name: Create Prod Release

on: 
    workflow_dispatch: 

# TODO maybe we don't need all
permissions: write-all 

jobs: 
    tag_version: 
        runs-on: ubuntu-latest
        outputs:
            new_tag: ${{ steps_tag_version.outputs.new_tag }}
        steps:
            - uses: actions/checkout@v2

            - name: Generate tag
                id: tag_version
                uses: mathieudutour/github-tag-action@6.1
                with:
                    github_token: ${{ secrets.GITHUB_TOKEN }}
                    dry_run: true

            - name: Update package.json and push
                run: |
                    git config user.name "${{ github.actor }} (Automated)"
                    git config user.email "github-actions@github.com"
                    npm version ${{ steps.tag_version.outputs.new_tag }}
                    git push 

            - name: Create Release Notes
                uses: ncipollo/release-action@v1
                with: 
                    tag: ${{ steps.tag_version.outputs.new_tag }}
                    name: Release ${{ steps.tag_version.outputs.new_tag }}
                    body: ${e steps.tag_version.outputs.changelog }}

    # optional deploy step
    #deploy:
    #    needs: tag_version
    #    uses: ./.github/workflows/deploy-workflow.yaml
    #      with: 
    #           tag: ${{ needs.tag_version.outputs.new_tag }}

I left parts commented out on purpose as the workflow by itself is more than useful however if you wanted further steps to be executed, like the deployment, then you can see how that might be done.

Give it a go and let me know if you find it useful!

Simplified deployments in a monorepo

Next up is a useful workflow for monorepos. Love them or hate them there's certainly some benefits to small to medium size monorepos when you're working on related projects and especially more so given the "gitops" style of deployments that are common.

Note

Huge, company-wide monorepos that you'd find at Google, Meta and the like are a different beast entirely and almost certinaly rely on custom tooling (and likely use modified versions of git). For these companies it makes a lot of sense to standardise across the business preventing repeated work and highlighting dependencies, to name a couple.

However there are still benefits to creating monorepos for smaller or personal projects. My blog is currently in a monorepo with the "builder" as I'm actively working on it and being able to keep everything in sync is valuable to me. There's also a bunch of tooling around if you're interested, things like Lerna and Turborepo are great for JS / TS repos.

The below workfow relies on the workflow_dispatch approach which requires a manual trigger over in the GitHub actions page for a given repo. This is actually pretty great for a monorepo as a tagged commit workflow would require some namespacing on order to trigger actions for a specific part of the repo.

The workflow is similar to that above using the mathieudutour/github-tag-action to generate tags for the change. The interesting part is the environment selection which allows the actions to build for prod and dev by simply tweaking the input option. This is further useful as it can be used to selectivley trigger creation of the ncipollo/release-action.

name: Create Release

on: 
    workflow_dispatch:
        inputs: 
            service: 
                description: "Select a service" 
                required: true
                type: choice
                options:
                - frontend
                - service_one
                - service_two
                - database
            environment:
                description: "Select target environment"
                required: true
                type: choice
                options: 
                - dev
                - staging
                - prod

permissions: write-all

jobs:
    tag_branch: 
        runs_on: ubuntu-latest
        outputs:
            new_tag: ${{ steps_tag_version.outputs.new_tag }}
        steps:
            - uses: actions/checkout@v2

            - name: Bump version and push tag 
                id: tag_version
                uses: mathieudutour/github-tag-action@6.1
                with:
                    github_token: ${{ secrets.GITHUB_TOKEN }}
                    tag_prefix: ${{ inputs.service }}-${{ inputs.environment }}-v

            - name: Create Release Notes
                if: ${{ inputs.environment == 'prod' }}
                uses: ncipollo/release-action@v1
                with: 
                    tag: ${{ steps.tag_version.outputs.new_tag }}
                    name: Release ${{ steps.tag_version.outputs.new_tag }}
                    body: ${e steps.tag_version.outputs.changelog }}

    deploy_frontend:
        needs: tag_branch
        if: ${{ inputs.service == 'frontend' }}
        uses: "./github/workflows/frontend-deploy.yaml"
        with:
            tag: ${{ needs.tag_branch.outputs.new_tag }}
        secrets: inherit

    deploy_service_one:
        needs: tag_branch
        if: ${{ inputs.service == 'service_one' }}
        ...
    
    deploy_service_two:
        needs: tag_branch
        if: ${{ inputs.service == 'service_two' }}
        ...

Once again the bulk of the action is implemented and the follow up deploy_.... steps are left as an example for the reader.

Lemme know what you think.

GitHub Actions are cool, mostly

I like the actions and they immediately pay dividends, however working with actions can be a real pain and there's many iterations needed to get to this point and that's partly the reason I'm writing this post.

Good luck and share you favourite actions with me over on Mastodon.

First appeared on Trusty Interior, last update 2 Nov 2024